forked from Tyris/m3uGoogleMusicSync
-
Notifications
You must be signed in to change notification settings - Fork 0
/
musicsync.py
266 lines (230 loc) · 10.3 KB
/
musicsync.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
"""
musicsync.py
Provides a utility class around the Google Music API that allows for easy synching of playlists.
Currently it will look at all the files already in the playlist and:
Upload any missing files (and add them to the playlist)
Add any files that are already uploaded but not in the online playlist
Optionally remove any files from the playlist that are not in the local copy (does not delete
files!)
Uploads are done one by one followed by a playlist update for each file (rather than as a
batch)
It does not remove duplicate entries from playlists or handle multiple entries.
TODO: Add optional duplicate remover
API used: https://github.com/simon-weber/Unofficial-Google-Music-API
Thanks to: Kevion Kwok and Simon Weber
Use at your own risk - especially for existing playlists
Free to use, reuse, copy, clone, etc
Usage:
ms = MusicSync()
# Will prompt for Email and Password - if 2-factor auth is on you'll need to generate a one-
time password
ms.sync_playlist("c:/path/to/playlist.m3u")
ms.delete_song("song_id")
"""
__author__ = "Tom Graham"
__email__ = "tom@sirwhite.com"
from gmusicapi import Webclient, Musicmanager
from gmusicapi.clients import OAUTH_FILEPATH
import mutagen
import json
import os
import time
import re
import codecs
from getpass import getpass
from httplib import BadStatusLine, CannotSendRequest
MAX_UPLOAD_ATTEMPTS_PER_FILE = 3
MAX_CONNECTION_ERRORS_BEFORE_QUIT = 5
STANDARD_SLEEP = 5
MAX_SONGS_IN_PLAYLIST = 1000
LOCAL_OAUTH_FILE = './oauth.cred'
class MusicSync(object):
def __init__(self, email=None, password=None):
self.mm = Musicmanager()
self.wc = Webclient()
if not email:
email = raw_input("Email: ")
if not password:
password = getpass()
self.email = email
self.password = password
self.logged_in = self.auth()
print "Fetching playlists from Google..."
self.playlists = self.wc.get_all_playlist_ids(auto=False)
print "Got %d playlists." % len(self.playlists['user'])
print "Fetching songs from Google..."
self.songs = self.wc.get_all_songs()
print "Got %d songs." % len(self.songs)
def auth(self):
self.logged_in = self.wc.login(self.email, self.password)
if not self.logged_in:
print "Login failed..."
exit()
print "Logged in as %s" % self.email
if not os.path.isfile(OAUTH_FILEPATH):
print "First time login. Please follow the instructions below:"
self.mm.perform_oauth()
self.logged_in = self.mm.login()
if not self.logged_in:
print "OAuth failed... try deleting your %s file and trying again." % OAUTH_FILEPATH
exit()
print "Authenticated"
def sync_playlist(self, filename, remove_missing=False):
filename = self.get_platform_path(filename)
os.chdir(os.path.dirname(filename))
title = os.path.splitext(os.path.basename(filename))[0]
print "Syncing playlist: %s" % filename
if title in self.playlists['user']:
plid = self.playlists['user'][title][0]
goog_songs = self.wc.get_playlist_songs(plid)
print " %d songs already in Google Music playlist" % len(goog_songs)
pc_songs = self.get_files_from_playlist(filename)
print " %d songs in local playlist" % len(pc_songs)
# Sanity check max 1000 songs per playlist
if len(pc_songs) > MAX_SONGS_IN_PLAYLIST:
print " Google music doesn't allow more than %d songs in a playlist..." % MAX_SONGS_IN_PLAYLIST
print " Will only attempt to sync the first %d songs." % MAX_SONGS_IN_PLAYLIST
del pc_songs[MAX_SONGS_IN_PLAYLIST:]
existing_files = 0
added_files = 0
failed_files = 0
removed_files = 0
fatal_count = 0
# print "Google songs: %s" % goog_songs
for fn in pc_songs:
if self.file_already_in_list(fn, goog_songs):
existing_files += 1
continue
print " adding: %s" % os.path.basename(fn)
online = self.find_song(fn)
song_id = None
if online:
song_id = online['id']
print " already uploaded [%s]" % song_id
else:
attempts = 0
result = []
while not result and attempts < MAX_UPLOAD_ATTEMPTS_PER_FILE:
print " uploading... (may take a while)"
attempts += 1
try:
result = self.mm.upload(fn)
except (BadStatusLine, CannotSendRequest):
# Bail out if we're getting too many disconnects
if fatal_count >= MAX_CONNECTION_ERRORS_BEFORE_QUIT:
print "Too many disconnections - quitting. Please try running the script again."
exit()
print "Connection Error -- Reattempting login"
fatal_count += 1
self.wc.logout()
self.mm.logout()
result = []
time.sleep(STANDARD_SLEEP)
except:
result = []
time.sleep(STANDARD_SLEEP)
try:
if result[0]:
song_id = result[0].itervalues().next()
else:
song_id = result[1].itervalues().next()
print " upload complete [%s]" % song_id
except:
print " upload failed - skipping"
if not song_id:
failed_files += 1
continue
added = self.wc.add_songs_to_playlist(plid, song_id)
time.sleep(.3) # Don't spam the server too fast...
print " done adding to playlist"
added_files += 1
if remove_missing:
for s in goog_songs:
print " removing: %s" % s['title']
self.wc.remove_songs_from_playlist(plid, s.id)
time.sleep(.3) # Don't spam the server too fast...
removed_files += 1
print "%d songs unmodified" % existing_files
print "%d songs added" % added_files
print "%d songs failed" % failed_files
print "%d songs removed" % removed_files
print "Ended: %s" % filename
else:
print " playlist %s didn't exist - skipping" % filename
def get_files_from_playlist(self, filename):
files = []
f = codecs.open(filename, encoding='utf-8')
for line in f:
line = line.rstrip().replace(u'\ufeff',u'')
if line == "" or line[0] == "#":
continue
path = os.path.abspath(self.get_platform_path(line))
if not os.path.exists(path):
print " file not found: %s" % line
continue
files.append(path)
f.close()
return files
def file_already_in_list(self, filename, goog_songs):
tag = self.get_id3_tag(filename)
i = 0
while i < len(goog_songs):
# print "checking: %s" % tag
# print "against: %s" % goog_songs[i]
if self.tag_compare(goog_songs[i], tag):
goog_songs.pop(i)
return True
i += 1
return False
def get_id3_tag(self, filename):
data = mutagen.File(filename, easy=True)
r = {}
if 'title' not in data:
title = os.path.splitext(os.path.basename(filename))[0]
print ' found song with no ID3 title, setting using filename:'
print ' %s' % title
print ' (please note - the id3 format used (v2.4) is invisible to windows)'
data['title'] = [title]
data.save()
r['title'] = data['title'][0]
r['track'] = int(data['tracknumber'][0].split('/')[0]) if 'tracknumber' in data else 0
# If there is no track, try and get a track number off the front of the file... since thats
# what google seems to do...
# Not sure how google expects it to be formatted, for now this is a best guess
if r['track'] == 0:
m = re.match("(\d+) ", os.path.basename(filename))
if m:
r['track'] = int(m.group(0))
r['artist'] = data['artist'][0] if 'artist' in data else ''
r['album'] = data['album'][0] if 'album' in data else ''
return r
def find_song(self, filename):
tag = self.get_id3_tag(filename)
# NOTE - diagnostic print here to check results if you're creating duplicates
print " [%s][%s][%s][%s]" % (tag['title'], tag['artist'], tag['album'], tag['track'])
for s in self.songs:
if self.tag_compare(s, tag):
print " %s matches %s" % (s['title'], tag['title'])
return s
print " No matches for %s" % tag['title']
return None
def tag_compare(self, g_song, tag):
# If a google result has no track, google doesn't return a field for it
if 'track' not in g_song:
g_song['track'] = 0
return g_song['title'].lower() == tag['title'].lower() and\
g_song['artist'].lower() == tag['artist'].lower() and\
g_song['album'].lower() == tag['album'].lower() and\
g_song['track'] == tag['track']
def delete_song(self, sid):
self.wc.delete_songs(sid)
print " deleted song by id [%s]" % sid
def get_platform_path(self, full_path):
# Try to avoid messing with the path if possible
if os.sep == '/' and '\\' not in full_path:
return full_path
if os.sep == '\\' and '\\' in full_path:
return full_path
if '\\' not in full_path:
return full_path
return os.path.normpath(full_path.replace('\\', '/'))