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`pull/7/head
parent
dea77e0eed
commit
923d11311a
|
@ -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()
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue