mirror of
https://zotify.xyz/zotify/zotify.git
synced 2025-06-22 17:26:43 +00:00
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:
parent
5da27d32a1
commit
4c242583b8
4 changed files with 97 additions and 5 deletions
15
README.md
15
README.md
|
@ -15,6 +15,7 @@
|
||||||
- Supports multiple audio formats
|
- Supports multiple audio formats
|
||||||
- Download directly from URL or use built-in in search
|
- 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
|
- 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. \
|
*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. \
|
**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
|
| CREDENTIALS_LOCATION | --credentials-location | | The location of the credentials.json
|
||||||
| OUTPUT | --output | | The output location/format (see below)
|
| 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
|
| SONG_ARCHIVE | --song-archive | | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED
|
||||||
| ROOT_PATH | --root-path | | Directory where Zotify saves music
|
| ROOT_PATH | --root-path | | Directory where Zotify saves music
|
||||||
| ROOT_PODCAST_PATH | --root-podcast-path | | Directory where Zotify saves podcasts
|
| 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
|
| 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.
|
| 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
|
| 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}
|
{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
|
### Docker Usage
|
||||||
```
|
```
|
||||||
Build the docker image from the Dockerfile:
|
Build the docker image from the Dockerfile:
|
||||||
|
|
|
@ -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, \
|
from zotify.const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \
|
||||||
OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE
|
OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE
|
||||||
from zotify.loader import Loader
|
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.podcast import download_episode, get_show_episodes
|
||||||
from zotify.termoutput import Printer, PrintChannel
|
from zotify.termoutput import Printer, PrintChannel
|
||||||
from zotify.track import download_track, get_saved_tracks, get_followed_artists
|
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)
|
download_album(album_id)
|
||||||
elif playlist_id is not None:
|
elif playlist_id is not None:
|
||||||
download = True
|
download = True
|
||||||
|
capture_only = Zotify.CONFIG.get_capture_only()
|
||||||
playlist_songs = get_playlist_songs(playlist_id)
|
playlist_songs = get_playlist_songs(playlist_id)
|
||||||
name, _ = get_playlist_info(playlist_id)
|
name, owner = get_playlist_info(playlist_id)
|
||||||
enum = 1
|
enum = 1
|
||||||
char_num = len(str(len(playlist_songs)))
|
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:
|
for song in playlist_songs:
|
||||||
if not song[TRACK][NAME] or not song[TRACK][ID]:
|
if not song[TRACK][NAME] or not song[TRACK][ID]:
|
||||||
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
|
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
|
||||||
else:
|
else:
|
||||||
|
tracks.append(song[TRACK][ID])
|
||||||
|
if capture_only:
|
||||||
|
continue
|
||||||
if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode
|
if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode
|
||||||
download_episode(song[TRACK][ID])
|
download_episode(song[TRACK][ID])
|
||||||
else:
|
else:
|
||||||
|
@ -120,6 +131,10 @@ def download_from_urls(urls: list[str]) -> bool:
|
||||||
'playlist_track_id': song[TRACK][ID]
|
'playlist_track_id': song[TRACK][ID]
|
||||||
})
|
})
|
||||||
enum += 1
|
enum += 1
|
||||||
|
|
||||||
|
if Zotify.CONFIG.get_playlist_capture():
|
||||||
|
write_playlist_to_file(playlist_id, name, owner, tracks)
|
||||||
|
|
||||||
elif episode_id is not None:
|
elif episode_id is not None:
|
||||||
download = True
|
download = True
|
||||||
download_episode(episode_id)
|
download_episode(episode_id)
|
||||||
|
|
|
@ -6,6 +6,9 @@ from typing import Any
|
||||||
|
|
||||||
ROOT_PATH = 'ROOT_PATH'
|
ROOT_PATH = 'ROOT_PATH'
|
||||||
ROOT_PODCAST_PATH = 'ROOT_PODCAST_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_EXISTING = 'SKIP_EXISTING'
|
||||||
SKIP_PREVIOUSLY_DOWNLOADED = 'SKIP_PREVIOUSLY_DOWNLOADED'
|
SKIP_PREVIOUSLY_DOWNLOADED = 'SKIP_PREVIOUSLY_DOWNLOADED'
|
||||||
DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT'
|
DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT'
|
||||||
|
@ -21,6 +24,7 @@ SONG_ARCHIVE = 'SONG_ARCHIVE'
|
||||||
SAVE_CREDENTIALS = 'SAVE_CREDENTIALS'
|
SAVE_CREDENTIALS = 'SAVE_CREDENTIALS'
|
||||||
CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
|
CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
|
||||||
OUTPUT = 'OUTPUT'
|
OUTPUT = 'OUTPUT'
|
||||||
|
PLAYLIST_OUTPUT = 'PLAYLIST_OUTPUT'
|
||||||
PRINT_SPLASH = 'PRINT_SPLASH'
|
PRINT_SPLASH = 'PRINT_SPLASH'
|
||||||
PRINT_SKIPS = 'PRINT_SKIPS'
|
PRINT_SKIPS = 'PRINT_SKIPS'
|
||||||
PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS'
|
PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS'
|
||||||
|
@ -41,9 +45,13 @@ CONFIG_VALUES = {
|
||||||
SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
|
SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
|
||||||
CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' },
|
CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' },
|
||||||
OUTPUT: { 'default': '', 'type': str, 'arg': '--output' },
|
OUTPUT: { 'default': '', 'type': str, 'arg': '--output' },
|
||||||
|
PLAYLIST_OUTPUT: { 'default': '', 'type': str, 'arg': '--playlist-output' },
|
||||||
SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' },
|
SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' },
|
||||||
ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' },
|
ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' },
|
||||||
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-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' },
|
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
|
||||||
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
|
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
|
||||||
MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' },
|
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_SINGLE = '{artist}/{album}/{artist} - {song_name}.{ext}'
|
||||||
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
|
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
|
||||||
|
|
||||||
|
PLAYLIST_OUTPUT_DEFAULT = '{name}.json'
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
Values = {}
|
Values = {}
|
||||||
|
@ -169,6 +179,23 @@ class Config:
|
||||||
Path(root_podcast_path).mkdir(parents=True, exist_ok=True)
|
Path(root_podcast_path).mkdir(parents=True, exist_ok=True)
|
||||||
return root_podcast_path
|
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
|
@classmethod
|
||||||
def get_skip_existing(cls) -> bool:
|
def get_skip_existing(cls) -> bool:
|
||||||
return cls.get(SKIP_EXISTING)
|
return cls.get(SKIP_EXISTING)
|
||||||
|
@ -305,6 +332,15 @@ class Config:
|
||||||
return OUTPUT_DEFAULT_ALBUM
|
return OUTPUT_DEFAULT_ALBUM
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_playlist_output(cls) -> str:
|
||||||
|
v = cls.get(PLAYLIST_OUTPUT)
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
return PLAYLIST_OUTPUT_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_retry_attempts(cls) -> int:
|
def get_retry_attempts(cls) -> int:
|
||||||
return cls.get(RETRY_ATTEMPTS)
|
return cls.get(RETRY_ATTEMPTS)
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from zotify.const import ITEMS, ID, TRACK, NAME
|
from zotify.const import ITEMS, ID, TRACK, NAME
|
||||||
from zotify.termoutput import Printer
|
from zotify.termoutput import Printer
|
||||||
from zotify.track import download_track
|
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
|
from zotify.zotify import Zotify
|
||||||
|
|
||||||
MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists'
|
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):
|
def get_playlist_info(playlist_id):
|
||||||
""" Returns information scraped from playlist """
|
""" Returns information scraped from playlist """
|
||||||
(raw, resp) = Zotify.invoke_url(f'{PLAYLISTS_URL}/{playlist_id}?fields=name,owner(display_name)&market=from_token')
|
(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']['display_name'].strip()
|
return resp['name'].strip(), resp['owner']
|
||||||
|
|
||||||
|
|
||||||
def download_playlist(playlist):
|
def download_playlist(playlist):
|
||||||
|
@ -81,3 +84,26 @@ def download_from_user_playlist():
|
||||||
download_playlist(playlist)
|
download_playlist(playlist)
|
||||||
|
|
||||||
print('\n**All playlists have been downloaded**\n')
|
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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue