Add support for downloading playlist metadata

Add option PLAYLIST_CAPTURE. When enabled, downloaded playlists will be stored
as JSON files.

This lets users parse those JSON files and write small tools that can recreate
Spotify playlists locally. This commit only adds the download capability, and
making playlists (as m3u files et.c.) is left as an exercise for the reader or
maybe a latter commit.
This commit is contained in:
Lonely Lyle 2024-05-11 13:28:03 +02:00
parent 5da27d32a1
commit 4c242583b8
4 changed files with 97 additions and 5 deletions

View file

@ -15,6 +15,7 @@
- Supports multiple audio formats
- Download directly from URL or use built-in in search
- Bulk downloads from a list of URLs in a text file or parsed directly as arguments
- Download playlist structure as JSON files so local playlists can be recreated
*Free accounts are limited to 160kbps. \
**Audio files are NOT substituted with ones from other sources such as YouTube or Deezer, they are sourced directly. \
@ -60,9 +61,13 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
|------------------------------|----------------------------------|----------|---------------------------------------------------------------------|
| CREDENTIALS_LOCATION | --credentials-location | | The location of the credentials.json
| OUTPUT | --output | | The output location/format (see below)
| PLAYLIST_OUTPUT | --playist-output | | The playlist metadata output location/format (see below)
| SONG_ARCHIVE | --song-archive | | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED
| ROOT_PATH | --root-path | | Directory where Zotify saves music
| ROOT_PODCAST_PATH | --root-podcast-path | | Directory where Zotify saves podcasts
| ROOT_PLAYLIST_PATH | --root-playlist-path | | Directory where Zotify saves playlist metadata
| PLAYLIST_CAPTURE | --playlist-capture | False | Saves each playlist metadata as JSON files
| CAPTURE_ONLY | --capture-only | False | Skips downloading songs when capturing playlist metadata
| SPLIT_ALBUM_DISCS | --split-album-discs | False | Saves each disk in its own folder
| DOWNLOAD_LYRICS | --download-lyrics | True | Downloads synced lyrics in .lrc format, uses unsynced as fallback.
| MD_ALLGENRES | --md-allgenres | False | Save all relevant genres in metadata
@ -127,6 +132,16 @@ Example values could be:
{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}
~~~~
For playlist metadata, it's the same as above, but with the `PLAYLIST_OUTPUT` parameter (`--playlist-output`).
The value is relative to `ROOT_PLAYLIST_PATH` and can contain the following placeholders.
| Placeholder | Description
|-----------------|--------------------------------
| {id} | The playlist id
| {name} | The playlist name
| {owner} | The display name of the owner account
| {owner_id} | The user id of the owner account
### Docker Usage
```
Build the docker image from the Dockerfile:

View file

@ -6,7 +6,8 @@ from zotify.album import download_album, download_artist_albums
from zotify.const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \
OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE
from zotify.loader import Loader
from zotify.playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
from zotify.playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, \
download_playlist, write_playlist_to_file
from zotify.podcast import download_episode, get_show_episodes
from zotify.termoutput import Printer, PrintChannel
from zotify.track import download_track, get_saved_tracks, get_followed_artists
@ -100,14 +101,24 @@ def download_from_urls(urls: list[str]) -> bool:
download_album(album_id)
elif playlist_id is not None:
download = True
capture_only = Zotify.CONFIG.get_capture_only()
playlist_songs = get_playlist_songs(playlist_id)
name, _ = get_playlist_info(playlist_id)
name, owner = get_playlist_info(playlist_id)
enum = 1
char_num = len(str(len(playlist_songs)))
tracks = []
if capture_only:
Printer.print(
PrintChannel.WARNINGS,
'### SKIPPING DOWNLOADS: ONLY CAPTURING PLAYLIST METADATA ###' + "\n"
)
for song in playlist_songs:
if not song[TRACK][NAME] or not song[TRACK][ID]:
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
else:
tracks.append(song[TRACK][ID])
if capture_only:
continue
if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode
download_episode(song[TRACK][ID])
else:
@ -120,6 +131,10 @@ def download_from_urls(urls: list[str]) -> bool:
'playlist_track_id': song[TRACK][ID]
})
enum += 1
if Zotify.CONFIG.get_playlist_capture():
write_playlist_to_file(playlist_id, name, owner, tracks)
elif episode_id is not None:
download = True
download_episode(episode_id)

View file

@ -6,6 +6,9 @@ from typing import Any
ROOT_PATH = 'ROOT_PATH'
ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH'
ROOT_PLAYLIST_PATH = 'ROOT_PLAYLIST_PATH'
PLAYLIST_CAPTURE = 'PLAYLIST_CAPTURE'
CAPTURE_ONLY = 'CAPTURE_ONLY'
SKIP_EXISTING = 'SKIP_EXISTING'
SKIP_PREVIOUSLY_DOWNLOADED = 'SKIP_PREVIOUSLY_DOWNLOADED'
DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT'
@ -21,6 +24,7 @@ SONG_ARCHIVE = 'SONG_ARCHIVE'
SAVE_CREDENTIALS = 'SAVE_CREDENTIALS'
CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
OUTPUT = 'OUTPUT'
PLAYLIST_OUTPUT = 'PLAYLIST_OUTPUT'
PRINT_SPLASH = 'PRINT_SPLASH'
PRINT_SKIPS = 'PRINT_SKIPS'
PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS'
@ -41,9 +45,13 @@ CONFIG_VALUES = {
SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' },
OUTPUT: { 'default': '', 'type': str, 'arg': '--output' },
PLAYLIST_OUTPUT: { 'default': '', 'type': str, 'arg': '--playlist-output' },
SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' },
ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' },
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
ROOT_PLAYLIST_PATH: { 'default': '', 'type': str, 'arg': '--root-playlist-path' },
PLAYLIST_CAPTURE: { 'default': 'False', 'type': bool, 'arg': '--playlist-capture' },
CAPTURE_ONLY: { 'default': 'False', 'type': bool, 'arg': '--capture-only' },
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' },
@ -77,6 +85,8 @@ OUTPUT_DEFAULT_LIKED_SONGS = 'Liked Songs/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_SINGLE = '{artist}/{album}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
PLAYLIST_OUTPUT_DEFAULT = '{name}.json'
class Config:
Values = {}
@ -169,6 +179,23 @@ class Config:
Path(root_podcast_path).mkdir(parents=True, exist_ok=True)
return root_podcast_path
@classmethod
def get_root_playlist_path(cls) -> str:
if cls.get(ROOT_PLAYLIST_PATH) == '':
root_playlist_path = PurePath(Path.home() / 'Music/Zotify Playlists/')
else:
root_playlist_path = PurePath(Path(cls.get(ROOT_PLAYLIST_PATH)).expanduser())
Path(root_playlist_path).mkdir(parents=True, exist_ok=True)
return root_playlist_path
@classmethod
def get_playlist_capture(cls) -> bool:
return cls.get(PLAYLIST_CAPTURE)
@classmethod
def get_capture_only(cls) -> bool:
return cls.get(CAPTURE_ONLY)
@classmethod
def get_skip_existing(cls) -> bool:
return cls.get(SKIP_EXISTING)
@ -305,6 +332,15 @@ class Config:
return OUTPUT_DEFAULT_ALBUM
raise ValueError()
@classmethod
def get_playlist_output(cls) -> str:
v = cls.get(PLAYLIST_OUTPUT)
if v:
return v
return PLAYLIST_OUTPUT_DEFAULT
@classmethod
def get_retry_attempts(cls) -> int:
return cls.get(RETRY_ATTEMPTS)

View file

@ -1,7 +1,10 @@
import json
from pathlib import Path
from zotify.const import ITEMS, ID, TRACK, NAME
from zotify.termoutput import Printer
from zotify.track import download_track
from zotify.utils import split_input
from zotify.utils import split_input, fix_filename
from zotify.zotify import Zotify
MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists'
@ -42,8 +45,8 @@ def get_playlist_songs(playlist_id):
def get_playlist_info(playlist_id):
""" Returns information scraped from playlist """
(raw, resp) = Zotify.invoke_url(f'{PLAYLISTS_URL}/{playlist_id}?fields=name,owner(display_name)&market=from_token')
return resp['name'].strip(), resp['owner']['display_name'].strip()
(raw, resp) = Zotify.invoke_url(f'{PLAYLISTS_URL}/{playlist_id}?fields=name,owner(id,display_name)&market=from_token')
return resp['name'].strip(), resp['owner']
def download_playlist(playlist):
@ -81,3 +84,26 @@ def download_from_user_playlist():
download_playlist(playlist)
print('\n**All playlists have been downloaded**\n')
def write_playlist_to_file(playlist_id, name, owner, tracks):
""" Saves playlist to JSON file """
output_template = Zotify.CONFIG.get_playlist_output()
output_template = output_template.replace("{id}", fix_filename(playlist_id))
output_template = output_template.replace("{owner}", fix_filename(owner['display_name']))
output_template = output_template.replace("{owner_id}", fix_filename(owner['id']))
output_template = output_template.replace("{name}", fix_filename(name))
playlist_file = Path(Zotify.CONFIG.get_root_playlist_path()).joinpath(output_template)
playlist_dir = Path(playlist_file).parent
playlist_dir.mkdir(parents=True, exist_ok=True)
with playlist_file.open("w+") as file:
data = {
"id": playlist_id,
"name": name,
"owner": owner,
"tracks": tracks,
}
json.dump(data, file, indent=4)