Browse Source

Upload files to ''

master
Chase Hall 3 months ago
parent
commit
f5ebbaa6a4
3 changed files with 519 additions and 54 deletions
  1. +5
    -4
      README.md
  2. +143
    -50
      spotify-backup.py
  3. +371
    -0
      xspf.py

+ 5
- 4
README.md View File

@ -3,10 +3,11 @@ spotify-backup
A Python 3 script that exports all of your Spotify playlists, useful for paranoid Spotify users like me, afraid that one day Spotify will go under and take all of our playlists with it!
Usage:
Run the script, and double-click it. It'll ask you for a filename and then pop open a web page so you can authorize access to the Spotify API. Then the script will load your datas.
You can have a tab-separated file with your playlists that you can open in Excel using `--format txt`, so you can even copy-paste the rows from Excel into a Spotify playlist.
python3 spotify-backup.py playlists.txt
You can also run the script from the command line:
Adding `--format=json` will give you a JSON dump with everything that the script gets from the Spotify API. If for some reason the browser-based authorization flow doesn't work, you can also [generate an OAuth token](https://developer.spotify.com/web-api/console/get-playlists/) on the developer site (with the `playlist-read-private` permission) and pass it with the `--token` option.
python spotify-backup.py data.json
Collaborative playlists and playlist folders don't show up in the API, sadly.
If for some reason the browser-based authorization flow doesn't work, you can also [generate an OAuth token](https://developer.spotify.com/web-api/console/get-playlists/) on the developer site (with `user-follow-read user-library-read playlist-read-private playlist-read-collaborative` permission) and pass it with the `--token` option.

+ 143
- 50
spotify-backup.py View File

@ -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:
@ -20,7 +16,7 @@ class SpotifyAPI:
self._auth = auth
# Gets a resource from the Spotify API and returns the object.
def get(self, url, params={}, tries=3):
def get(self, url, params={}, tries=3, root=''):
# Construct the correct URL.
if not url.startswith('https://api.spotify.com/v1/'):
url = 'https://api.spotify.com/v1/' + url
@ -34,7 +30,10 @@ class SpotifyAPI:
req.add_header('Authorization', 'Bearer ' + self._auth)
res = urllib.request.urlopen(req)
reader = codecs.getreader('utf-8')
return json.load(reader(res))
response = json.load(reader(res))
if root:
response = response[root]
return response
except Exception as err:
log('Couldn\'t load URL: {} ({})'.format(url, err))
time.sleep(2)
@ -43,12 +42,15 @@ class SpotifyAPI:
# The Spotify API breaks long lists into multiple pages. This method automatically
# fetches all pages and joins them, returning in a single list of objects.
def list(self, url, params={}):
response = self.get(url, params)
def list(self, url, params={}, root=''):
response = self.get(url, params, root=root)
items = response['items']
while response['next']:
response = self.get(response['next'])
items += response['items']
print('.', end='')
sys.stdout.flush()
print()
return items
# Pops open a browser window for a user to log in and authorize API access.
@ -110,9 +112,9 @@ class SpotifyAPI:
def __init__(self, access_token):
self.access_token = access_token
def log(str):
def log(str, end="\n"):
#print('[{}] {}'.format(time.strftime('%I:%M:%S'), str).encode(sys.stdout.encoding, errors='replace'))
sys.stdout.buffer.write('[{}] {}\n'.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():
@ -120,50 +122,141 @@ def main():
parser = argparse.ArgumentParser(description='Exports your Spotify playlists. By default, opens a browser window '
+ 'to authorize the Spotify Web API, but you can also manually specify'
+ ' an OAuth token with the --token option.')
parser.add_argument('--token', metavar='OAUTH_TOKEN', help='use a Spotify OAuth token (requires the '
parser.add_argument('-t', '--token', metavar='OAUTH_TOKEN', help='use a Spotify OAuth token (requires the '
+ '`playlist-read-private` permission)')
parser.add_argument('--format', default='txt', choices=['json', 'txt'], help='output format (default: txt)')
parser.add_argument('file', help='output filename', nargs='?')
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 (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): ')
# Log into the Spotify API.
if args.token:
spotify = SpotifyAPI(args.token)
if args.load:
with open(args.load, 'r', encoding='utf-8') as f:
data = json.load(f)
else:
spotify = SpotifyAPI.authorize(client_id='5c098bcc800e45d49e476265bc9b6934', scope='playlist-read-private')
# Get the ID of the logged in user.
me = spotify.get('me')
log('Logged in as {display_name} ({id})'.format(**me))
# Log into the Spotify API.
if args.token:
spotify = SpotifyAPI(args.token)
else:
spotify = SpotifyAPI.authorize(client_id='5c098bcc800e45d49e476265bc9b6934', scope='user-follow-read user-library-read playlist-read-private playlist-read-collaborative')
# me https://developer.spotify.com/web-api/get-current-users-profile/
# follow['artists] https://developer.spotify.com/web-api/get-followed-artists/
# albums https://developer.spotify.com/web-api/get-users-saved-albums/
# tracks https://developer.spotify.com/web-api/get-users-saved-tracks/
# playlists https://developer.spotify.com/web-api/console/get-playlists/?user_id=wizzler
data = {}
# Get the ID of the logged in user.
data['me'] = spotify.get('me')
log('Logged in as {display_name} ({id})'.format(**data['me']))
# List all playlists and all track in each playlist.
playlists = spotify.list('users/{user_id}/playlists'.format(user_id=me['id']), {'limit': 50})
for playlist in playlists:
log('Loading playlist: {name} ({tracks[total]} songs)'.format(**playlist))
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(playlists, f)
# Get follows - scope user-follow-read
# "root" workaround for non-consistent API ..
data['following'] = {}
following = spotify.get('me/following', {'type': 'artist', 'limit': 1}, root='artists')
log('Loading followed artists: {total} artists'.format(**following), end='')
data['following']['artists'] = spotify.list('me/following', {'type': 'artist', 'limit': 50}, root='artists')
# Tab-separated file.
elif args.format == 'txt':
for playlist in playlists:
f.write(playlist['name'] + '\r\n')
for track in playlist['tracks']:
f.write('{name}\t{artists}\t{album}\r\n'.format(
name=track['track']['name'],
artists=', '.join([artist['name'] for artist in track['track']['artists']]),
album=track['track']['album']['name']
))
f.write('\r\n')
log('Wrote file: ' + args.file)
# List saved albums - scope user-library-read
albums = spotify.get('me/albums', {'limit': 1})
log('Loading saved albums: {total} albums'.format(**albums), end='')
data['albums'] = spotify.list('me/albums', {'limit': 50})
# List saved tracks - scope user-library-read
tracks = spotify.get('me/tracks', {'limit': 1})
log('Loading tracks: {total} songs'.format(**tracks), end='')
data['tracks'] = spotify.list('me/tracks', {'limit': 50})
# List all playlists and all track in each playlist - scope playlist-read-private, playlist-read-collaborative
data['playlists'] = spotify.list('users/{user_id}/playlists'.format(user_id=data['me']['id']), {'limit': 50})
for playlist in data['playlists']:
log('Loading playlist: {name} ({tracks[total]} songs)'.format(**playlist), end='')
playlist['tracks'] = spotify.list(playlist['tracks']['href'], {'limit': 100})
# 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()

+ 371
- 0
xspf.py View File

@ -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…
Cancel
Save