From 923d11311a6ae41bb77c538f30ccc8a2042fc467 Mon Sep 17 00:00:00 2001 From: Alexandre L Date: Sat, 3 Jun 2017 01:15:19 +0200 Subject: [PATCH] Add xspf support With some materials from https://github.com/jnylen/spotify-backup, and thanks @Iristyle for the notification. Assuming you saved in json, you can convert them with something like `py -3 .\spotify-backup.py -l .\spotify_2017-06-02.json -f xspf xspf_playlists_directory` --- spotify-backup.py | 134 +++++++++++------ xspf.py | 371 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 459 insertions(+), 46 deletions(-) create mode 100644 xspf.py diff --git a/spotify-backup.py b/spotify-backup.py index 38d81fe..28c84fc 100755 --- a/spotify-backup.py +++ b/spotify-backup.py @@ -1,17 +1,13 @@ #!/usr/bin/env python3 -import argparse +import sys, os, re, time +import argparse import codecs -import http.client +import urllib.parse, urllib.request, urllib.error import http.server -import json -import re -import sys -import time -import urllib.error -import urllib.parse -import urllib.request import webbrowser +import json +import xspf class SpotifyAPI: @@ -118,7 +114,7 @@ class SpotifyAPI: def log(str, end="\n"): #print('[{}] {}'.format(time.strftime('%I:%M:%S'), str).encode(sys.stdout.encoding, errors='replace')) - sys.stdout.buffer.write(('[{}] {}'+end).format(time.strftime('%I:%M:%S'), str).encode(sys.stdout.encoding, errors='replace')) + sys.stdout.buffer.write(('[{}] {}'+end).format(time.strftime('%H:%M:%S'), str).encode(sys.stdout.encoding, errors='replace')) sys.stdout.flush() def main(): @@ -128,15 +124,15 @@ def main(): + ' an OAuth token with the --token option.') parser.add_argument('-t', '--token', metavar='OAUTH_TOKEN', help='use a Spotify OAuth token (requires the ' + '`playlist-read-private` permission)') - parser.add_argument('-f', '--format', default='json', choices=['json', 'txt', 'md'], help='output format (default: json)') + parser.add_argument('-f', '--format', default='json', choices=['json', 'xspf', 'txt', 'md'], help='output format (default: json)') parser.add_argument('-l', '--load', metavar='JSON_FILE', help='load an existing json file to create txt or markdown output (playlists only currently)') parser.add_argument('-i', '--indent', metavar='INDENT_STR', default=None, help='indent JSON output') - parser.add_argument('file', help='output filename', nargs='?') + parser.add_argument('file', help='output filename (or directory for xspf)', nargs='?') args = parser.parse_args() # If they didn't give a filename, then just prompt them. (They probably just double-clicked.) while not args.file: - args.file = input('Enter a file name (e.g. playlists.txt): ') + args.file = input('Enter a file name (e.g. playlists.txt) or directory (xspf format): ') if args.load: with open(args.load, 'r', encoding='utf-8') as f: @@ -182,39 +178,85 @@ def main(): log('Loading playlist: {name} ({tracks[total]} songs)'.format(**playlist), end='') playlist['tracks'] = spotify.list(playlist['tracks']['href'], {'limit': 100}) - # Write the file. - with open(args.file, 'w', encoding='utf-8') as f: - # JSON file. - if args.format == 'json': - json.dump(data, f, indent=args.indent) - - # Tab-separated file. - elif args.format == 'txt': - for playlist in data['playlists']: - f.write(playlist['name'] + "\n") - for track in playlist['tracks']: - f.write('{name}\t{artists}\t{album}\t{uri}\n'.format( - uri=track['track']['uri'], - name=track['track']['name'], - artists=', '.join([artist['name'] for artist in track['track']['artists']]), - album=track['track']['album']['name'] - )) - f.write('\n') - - # Markdown - elif args.format == 'md': - f.write("# Spotify Playlists Backup " + time.strftime("%d %b %Y") + "\n") - for playlist in data['playlists']: - f.write("## " + playlist["name"] + "\n") - for track in playlist['tracks']: - f.write("* {name}\t{artists}\t{album}\t`{uri}`\n".format( - uri=track["track"]["uri"], - name=track["track"]["name"], - artists=", ".join([artist["name"] for artist in track["track"]["artists"]]), - album=track["track"]["album"]["name"] - )) - f.write("\n") - log('Wrote file: ' + args.file) + # Write the file(s). + if args.format == 'xspf': + # Create the specified directory + if not os.path.exists(args.file): + os.makedirs(args.file) + mkvalid_filename = re.compile(r'[/\\:*?"<>|]') + # Fake the special tracks playlist as regular playlist + data['playlists'].append({'id': 'saved-tracks', 'name': 'Saved tracks', 'tracks': data['tracks']}) + # Playlists + for playlist in data['playlists']: + valid_filename = mkvalid_filename.sub('', playlist['name']) + with open('{}{}{}___{}.xspf'.format(args.file, os.sep, valid_filename, playlist['id']), 'w', encoding='utf-8') as f: # Avoid conflicts using id + try: + x = xspf.Xspf(title=playlist['name']) + for track in playlist['tracks']: + x.add_track( + title=track['track']['name'], + album=track['track']['album']['name'], + creator=', '.join([artist['name'] for artist in track['track']['artists']]) + ) + f.write(x.toXml().decode('utf-8')) + except Exception as e: + log('Failed in playlist {} ({}) : {}'.format(playlist['id'], playlist['name'], e)) + # Saved albums -- different format & more informations + for album in data['albums']: + artist = ', '.join(a['name'] for a in album['album']['artists']) + filename = 'Saved album - '+artist+' - '+album['album']['name'] + valid_filename = mkvalid_filename.sub('', filename) + with open('{}{}{}___{}.xspf'.format(args.file, os.sep, valid_filename, album['album']['id']), 'w', encoding='utf-8') as f: # Avoid conflicts using id + try: + x = xspf.Xspf( + date=album['album']['release_date'], + creator=artist, + title=album['album']['name'] + ) + for track in album['album']['tracks']['items']: + x.add_track( + title=track['name'], + album=album['album']['name'], + creator=', '.join([artist['name'] for artist in track['artists']]), + duration=str(track['duration_ms']), + trackNum=str(track['track_number']), + ) + f.write(x.toXml().decode('utf-8')) + except Exception as e: + log('Failed in playlist {} ({}) : {}'.format(album['album']['id'], filename, e)) + else: + with open(args.file, 'w', encoding='utf-8') as f: + # JSON file. + if args.format == 'json': + json.dump(data, f, indent=args.indent) + + # Tab-separated file. + elif args.format == 'txt': + for playlist in data['playlists']: + f.write(playlist['name'] + "\n") + for track in playlist['tracks']: + f.write('{name}\t{artists}\t{album}\t{uri}\n'.format( + uri=track['track']['uri'], + name=track['track']['name'], + artists=', '.join([artist['name'] for artist in track['track']['artists']]), + album=track['track']['album']['name'] + )) + f.write('\n') + + # Markdown + elif args.format == 'md': + f.write("# Spotify Playlists Backup " + time.strftime("%d %b %Y") + "\n") + for playlist in data['playlists']: + f.write("## " + playlist["name"] + "\n") + for track in playlist['tracks']: + f.write("* {name}\t{artists}\t{album}\t`{uri}`\n".format( + uri=track["track"]["uri"], + name=track["track"]["name"], + artists=", ".join([artist["name"] for artist in track["track"]["artists"]]), + album=track["track"]["album"]["name"] + )) + f.write("\n") + log('Wrote file: ' + args.file) if __name__ == '__main__': main() diff --git a/xspf.py b/xspf.py new file mode 100644 index 0000000..654623d --- /dev/null +++ b/xspf.py @@ -0,0 +1,371 @@ +#!/usr/bin/python + +import xml.etree.ElementTree as ET + +class XspfBase(object): + NS = "http://xspf.org/ns/0/" + + def _addAttributesToXml(self, parent, attrs): + for attr in attrs: + value = getattr(self, attr) + if value: + el = ET.SubElement(parent, "{{{0}}}{1}".format(self.NS, attr)) + el.text = value + + def _addDictionaryElements(self, parent, name, values): + # Sort keys so we have a stable order of items for testing. + # Alternative would be SortedDict, but is >=2.7 + for k in sorted(values.keys()): + el = ET.SubElement(parent, "{{{0}}}{1}".format(self.NS, name)) + el.set("rel", k) + el.text = values[k] + +# Avoid namespace prefixes, VLC doesn't like it +if hasattr(ET, 'register_namespace'): + ET.register_namespace('', XspfBase.NS) + +# in-place prettyprint formatter +# From http://effbot.org/zone/element-lib.htm +def indent(elem, level=0): + i = "\n" + level*" " + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + indent(elem, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + +class Xspf(XspfBase): + def __init__(self, obj={}, **kwargs): + self.version = "1" + + self._title = "" + self._creator = "" + self._info = "" + self._annotation = "" + self._location = "" + self._identifier = "" + self._image = "" + self._date = "" + self._license = "" + self._attributions = [] + self._link = {} + self._meta = {} + + self._trackList = [] + + if len(obj): + if "playlist" in obj: + obj = obj["playlist"] + for k, v in list(obj.items()): + setattr(self, k, v) + + if len(kwargs): + for k, v in list(kwargs.items()): + setattr(self, k, v) + + @property + def title(self): + """A human-readable title for the playlist. Optional""" + return self._title + @title.setter + def title(self, title): + self._title = title + + @property + def creator(self): + """Human-readable name of the entity (author, authors, group, company, etc) + that authored the playlist. Optional""" + return self._creator + @creator.setter + def creator(self, creator): + self._creator = creator + + @property + def annotation(self): + """A human-readable comment on the playlist. This is character data, + not HTML, and it may not contain markup. Optional""" + return self._annotation + @annotation.setter + def annotation(self, annotation): + self._annotation = annotation + + @property + def info(self): + """URI of a web page to find out more about this playlist. Optional""" + return self._info + @info.setter + def info(self, info): + self._info = info + + @property + def location(self): + """Source URI for this playlist. Optional""" + return self._location + @location.setter + def location(self, location): + self._location = location + + @property + def identifier(self): + """Canonical ID for this playlist. Likely to be a hash or other + location-independent name. Optional""" + return self._identifier + @identifier.setter + def identifier(self, identifier): + self._identifier = identifier + + @property + def image(self): + """URI of an image to display in the absence of a trackList/image + element. Optional""" + return self._image + @image.setter + def image(self, image): + self._image = image + + @property + def date(self): + """Creation date (not last-modified date) of the playlist. Optional""" + return self._date + @date.setter + def date(self, date): + self._date = date + + @property + def license(self): + """URI of a resource that describes the license under which this + playlist was released. Optional""" + return self._license + @license.setter + def license(self, license): + self._license = license + + @property + def meta(self): + return self._meta + + def add_meta(self, key, value): + """Add a meta element to the playlist.""" + self._meta[key] = value + + def del_meta(self, key): + """Remove a meta element.""" + del self._meta[key] + + def add_link(self, key, value): + """Add a link element to the playlist.""" + self._link[key] = value + + def del_link(self, key): + """Remove a link element.""" + del self._link[key] + + def add_attribution(self, location, identifier): + self.attrbutions.append((location, identifier)) + + def truncate_attributions(self, numattributions): + self.attrbutions = self.attributions[-numattributions:] + + # Todo: Attribution, Link, Meta, Extension + + def add_extension(self, application): + pass + + def make_extension_element(self, namespace, name, attributes, value): + pass + + def remove_extension(self, application): + pass + + @property + def track(self): + return self._trackList + @track.setter + def track(self, track): + self.add_track(track) + + def add_track(self, track={}, **kwargs): + if isinstance(track, list): + for t in track: + self.add_track(t) + elif isinstance(track, Track): + self._trackList.append(track) + elif isinstance(track, dict) and len(track) > 0: + self._trackList.append(Track(track)) + elif len(kwargs) > 0: + self._trackList.append(Track(kwargs)) + + def add_tracks(self, tracks): + for t in tracks: + self.add_track(t) + + def toXml(self, encoding="utf-8", pretty_print=True): + root = ET.Element("{{{0}}}playlist".format(self.NS)) + root.set("version", self.version) + + self._addAttributesToXml(root, ["title", "info", "creator", "annotation", + "location", "identifier", "image", "date", "license"]) + + self._addDictionaryElements(root, "link", self._link) + self._addDictionaryElements(root, "meta", self._meta) + + if len(self._trackList): + track_list = ET.SubElement(root, "{{{0}}}trackList".format(self.NS)) + for track in self._trackList: + track_list = track.getXmlObject(track_list) + if pretty_print: + indent(root) + return ET.tostring(root, encoding) + +class Track(XspfBase): + def __init__(self, obj={}, **kwargs): + self._location = "" + self._identifier = "" + self._title = "" + self._creator = "" + self._annotation = "" + self._info = "" + self._image = "" + self._album = "" + self._trackNum = "" + self._duration = "" + self._link = {} + self._meta = {} + + if len(obj): + for k, v in list(obj.items()): + setattr(self, k, v) + + if len(kwargs): + for k, v in list(kwargs.items()): + setattr(self, k, v) + + @property + def location(self): + """URI of resource to be rendered. Probably an audio resource, but MAY be any type of + resource with a well-known duration. Zero or more""" + return self._location + @location.setter + def location(self, location): + self._location = location + + @property + def identifier(self): + """ID for this resource. Likely to be a hash or other location-independent name, + such as a MusicBrainz identifier. MUST be a legal URI. Zero or more""" + return self._identifier + @identifier.setter + def identifier(self, identifier): + self._identifier = identifier + + @property + def title(self): + """Human-readable name of the track that authored the resource which defines the + duration of track rendering. Optional""" + return self._title + @title.setter + def title(self, title): + self._title = title + + @property + def creator(self): + """Human-readable name of the entity (author, authors, group, company, etc) that authored + the resource which defines the duration of track rendering.""" + return self._creator + @creator.setter + def creator(self, creator): + self._creator = creator + + @property + def annotation(self): + """A human-readable comment on the track. This is character data, not HTML, + and it may not contain markup.""" + return self._annotation + @annotation.setter + def annotation(self, annotation): + self._annotation = annotation + + @property + def info(self): + """URI of a place where this resource can be bought or more info can be found. Optional""" + return self._info + @info.setter + def info(self, info): + self._info = info + + @property + def image(self): + """URI of an image to display for the duration of the track. Optional""" + return self._image + @image.setter + def image(self, image): + self._image = image + + @property + def album(self): + """Human-readable name of the collection from which the resource which defines + the duration of track rendering comes. Optional""" + return self._album + @album.setter + def album(self, album): + self._album = album + + @property + def trackNum(self): + """Integer with value greater than zero giving the ordinal position of the media + on the album. Optional""" + return self._trackNum + @trackNum.setter + def trackNum(self, trackNum): + self._trackNum = trackNum + + @property + def duration(self): + """The time to render a resource, in milliseconds. Optional""" + return self._duration + @duration.setter + def duration(self, duration): + self._duration = duration + + @property + def meta(self): + return self._meta + + def add_meta(self, key, value): + """Add a meta element to the playlist.""" + self._meta[key] = value + + def del_meta(self, key): + """Remove a meta element.""" + del self._meta[key] + + def add_link(self, key, value): + """Add a link element to the playlist.""" + self._link[key] = value + + def del_link(self, key): + """Remove a link element.""" + del self._link[key] + + # Todo: Link, Meta, Extension + + def getXmlObject(self, parent): + track = ET.SubElement(parent, "{{{0}}}track".format(self.NS)) + + self._addAttributesToXml(track, ["location", "identifier", "title", "creator", + "annotation", "info", "image", "album", + "trackNum", "duration"]) + + self._addDictionaryElements(track, "link", self._link) + self._addDictionaryElements(track, "meta", self._meta) + + return parent + +Spiff = Xspf