forked from shanet/Google-Music-Playlist-Sync
/
google-music-playlist-sync.py
executable file
·334 lines (264 loc) · 12.1 KB
/
google-music-playlist-sync.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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
#!/usr/bin/env python
# Shane Tully (shane@shanetully.com)
# shanetully.com
# GitHub repo: https://github.com/shanet/Google-Music-Playlist-Sync
# Makes use of the Unofficial Google Music API by Simon Weber
# https://github.com/simon-weber/Unofficial-Google-Music-API
# Copyright (C) 2013 Shane Tully
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import re
import difflib
import argparse
from os import path
from getpass import getpass
from xml.etree.ElementTree import parse
from mutagen.easyid3 import EasyID3
from mutagen.easymp4 import EasyMP4
from mutagen.flac import FLAC
from mutagen.id3 import ID3NoHeaderError
from gmusicapi import Mobileclient
import pprint
def main():
[user, root_dir, playlists] = parse_cmdline_args()
# Show some pretty ASCII art
print " ____ _ __ __ _ ____ _ _ _ _ ____ "
print " / ___| ___ ___ __ _| | ___ | \/ |_ _ ___(_) ___ | _ \| | __ _ _ _| (_)___| |_ / ___| _ _ _ __ ___ "
print "| | _ / _ \ / _ \ / _` | |/ _ \ | |\/| | | | / __| |/ __| | |_) | |/ _` | | | | | / __| __| \___ \| | | | '_ \ / __|"
print "| |_| | (_) | (_) | (_| | | __/ | | | | |_| \__ \ | (__ | __/| | (_| | |_| | | \__ \ |_ ___) | |_| | | | | (__ "
print " \____|\___/ \___/ \__, |_|\___| |_| |_|\__,_|___/_|\___| |_| |_|\__,_|\__, |_|_|___/\__| |____/ \__, |_| |_|\___|"
print " |___/ |___/ |___/ "
print "\nThis script will sync a local XSPF or M3U format playlist, to a playlist on Google Music. Use the Google Music uploader to\nfirst upload the songs in the playlist.\n"
# Log in to Google Music
api = login_to_google_music(user)
# Get all songs in the library
print "Retrieving all songs in library. This may take a minute..."
remote_library = api.get_all_songs()
for playlist in playlists:
print "Syncing playlist: " + playlist
process_playlist(api, playlist, remote_library, root_dir)
# Be a good citizen and log out
api.logout()
print "Bye!"
exit(0)
def parse_cmdline_args():
argvParser = argparse.ArgumentParser()
argvParser.add_argument('-u', '--user', dest='user', nargs='?', help="The Google username/email to log in with.")
argvParser.add_argument('-r', '--root-dir', dest='root_dir', nargs='?', default='./', help="The root directory of a music directory. Useful for M3U playlists.")
argvParser.add_argument('playlists', nargs='+', help="The filenames of playlists.")
args = argvParser.parse_args()
# If the root directory doesn't have a directory separator at the end, add it
if not args.root_dir.endswith('/'):
args.root_dir += '/'
return [args.user, args.root_dir, args.playlists]
def login_to_google_music(user):
api = Mobileclient()
attempts = 0
while attempts < 3:
if user == None:
user = raw_input("Google username or email: ")
# Try to read the password from a file
# If file doesn't exist, ask for password
# This is useful for 2-step authentication only
# Don't store your regular password in plain text!
try:
pw_file = open("pass.txt")
password = pw_file.readline()
print "Reading password from pass.txt."
except IOError:
password = getpass()
print "\nLogging in..."
if api.login(user, password):
return api
else:
print "Login failed."
# Set the username to none so it is prompted to be re-entered on the next loop iteration
user = None
attempts += 1
print str(attempts) + " failed login attempts. Giving up."
exit(0)
def process_playlist(api, local_playlist_path, remote_library, root_dir):
# Get the filename. This will be used as the playlist name.
local_playlist_name, local_playlist_type = path.splitext(path.basename(local_playlist_path))
# Check that the file extension and parse the playlist
if local_playlist_type == ".xspf":
(name, local_tracks) = parse_xml(local_playlist_path)
# If the xml contained a playlist name, use that instead of the filename
if name:
local_playlist_name = name
elif local_playlist_type == ".m3u":
local_tracks = parse_m3u(local_playlist_path, root_dir)
else:
print "Error: Playlist " + local_playlist_name + " must be XSPF or M3U format."
return
# Check that the playlist has tracks in it
if len(local_tracks) == 0:
print "Error: Playlist " + local_playlist_name + " is empty."
return
# Sync the playlist
if not sync_playlist(api, remote_library, local_tracks , local_playlist_name):
print "Syncing playlist " + local_playlist_name + " failed."
return
def parse_xml(local_playlist_path):
# Parse the playlist XML file
xml_root = parse(local_playlist_path).getroot()
# Get the playlist title
playlist_name = None
title_element = xml_root.find("{http://xspf.org/ns/0/}title")
if not title_element is None:
playlist_name = title_element.text.strip()
# Get the list of tracks in the playlists
tracks_elements = xml_root.find("{http://xspf.org/ns/0/}trackList")
if tracks_elements is None:
print "Error: Malformed or empty playlist."
exit(1)
# Convert the XML elements to a dict
tracks = []
for track in tracks_elements:
ntrack = {}
for field in track:
if field.tag == "{http://xspf.org/ns/0/}title":
ntrack['title'] = field.text.strip()
elif field.tag == "{http://xspf.org/ns/0/}creator":
ntrack['artist'] = field.text.strip()
elif field.tag == "{http://xspf.org/ns/0/}album":
ntrack['album'] = field.text.strip()
elif field.tag == "{http://xspf.org/ns/0/}location":
ntrack['path'] = field.text.strip()
tracks.append(ntrack)
return (playlist_name, tracks)
def parse_m3u(local_playlist_path, root_dir):
playlist = open(local_playlist_path, 'r')
# Convert the tracks in the playlist into a dict
tracks = []
for line in playlist:
# Remove the newline from the end of the string
line = line.rstrip()
format = get_song_format(line)
try:
if format == "mp3":
song = EasyID3(root_dir + line)
elif format == "mp4" or format == "m4a":
song = EasyMP4(root_dir + line)
elif format == "flac":
song = FLAC(root_dir + line)
else:
print "\"" + line + "\" is not a supported format. Supported formats are MP3, MP4, M4A, or FLAC."
continue
except ID3NoHeaderError:
print "\"" + filename + "\" does not contain an ID3 tag."
# IO errors are most likely file not found errors or the wrong format file
except IOError as ioe:
print "\"" + line + "\": " + ioe.strerror
# Only take the first
track = {}
track['title'] = song['title'][0]
track['artist'] = song['artist'][0]
track['album'] = song['album'][0]
track['path'] = root_dir + line
tracks.append(track)
return tracks
def get_song_format(filename):
# Use the filename extension as the format
return path.splitext(path.basename(filename))[1].lower()[1:]
def clean_string(string):
# Strip whitespaces and use lowercase
string = string.strip()
string = string.lower()
# Remove (feat. [some artist])
patterns = ['^(.*?)\(feat\..*?\).*?$', '^(.*?)feat\..*?$']
for pattern in patterns:
reg = re.search(pattern, string)
if reg:
string = reg.group(1)
return string
def find_track(l_track, trackList):
seqMatchArtist = difflib.SequenceMatcher(None, "foobar", clean_string(l_track['artist']))
seqMatchTitle = difflib.SequenceMatcher(None, "foobar", clean_string(l_track['title']))
bestMatch = 0
for remoteTrack in trackList:
seqMatchArtist.set_seq1(clean_string(remoteTrack['artist']))
seqMatchTitle.set_seq1(clean_string(remoteTrack['title']))
scoreArtist = seqMatchArtist.quick_ratio()
scoreTitle = seqMatchTitle.quick_ratio()
totalScore = (scoreArtist + scoreTitle) / 2
if totalScore == 1:
return remoteTrack
elif totalScore > bestMatch:
bestMatch = totalScore
bestMatchTrack = remoteTrack
if bestMatch >= 0.85:
return bestMatchTrack
else:
return False
def sync_playlist(api, remote_library, local_tracks, local_playlist_name):
# Get all available playlists from Google Music
remote_playlists = api.get_all_playlists(False, False)
# Try to find the playlist if it already exists
remote_playlist_id = None
for item in remote_playlists:
if item['name'] == local_playlist_name:
# TODO: Handle multiple playlists with the same name
remote_playlist_id = item['id']
print "Found playlist with ID: " + remote_playlist_id
break
# If the playlist wasn't found, create it
if remote_playlist_id is None:
print "Playlist not found on Google Music. Creating it."
remote_playlist_id = api.create_playlist(local_playlist_name)
else:
print "A playlist already exists with that name. Updating existing playlists is not currently supported."
return False
# TODO: Reintegrate checking existing playlists
# Get the songs on the playlist
#remote_tracks = api.get_playlist_songs(remote_playlist_id)
# Check if each track in the local playlist is on the Google Music playlist
tracks_to_add_names = []
tracks_to_add_ids = []
for local_track in local_tracks:
added = False
# Check if the track is already present in the playlist
#if find_track(local_track, remote_tracks) != False:
# added = True
# Add the track to the playlist
if not added:
# Find the song ID
local_track_id = None
matchedTrack = find_track(local_track, remote_library)
if matchedTrack:
local_track_id = matchedTrack['id']
tracks_to_add_names.append(matchedTrack['artist'] + " - " + matchedTrack['title'])
tracks_to_add_ids.append(matchedTrack['id'])
# Check if the song wasn't found in the library
if local_track_id == None:
print "Error: Track \"" + local_track["artist"] + " - " + local_track['title'] + "\" in local playlist, but not found in Google Music library. Skipping this track."
continue
# Check that there are tracks to add
if len(tracks_to_add_ids) == 0:
print "\nPlaylist is already up-to-date."
return True
# Print the songs about to be added
print "Tracks to be added:"
for track in tracks_to_add_names:
print "\t" + track
print "\nThe above tracks will be added to the playlist \"" + local_playlist_name + "\""
if raw_input("Is this okay? (y,n) ") == "y":
# Finally, add the new track to the playlist
for track_id in tracks_to_add_ids:
api.add_songs_to_playlist(remote_playlist_id, track_id)
print "\nTracks added to playlist."
else:
print "Sorry!"
return False
return True
if __name__ == '__main__':
main()