diff --git a/README.md b/README.md index e54b822..211c47e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/zotify/app.py b/zotify/app.py index f15821b..6c150a0 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -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) diff --git a/zotify/config.py b/zotify/config.py index 802d5ce..95ff30c 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -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) diff --git a/zotify/playlist.py b/zotify/playlist.py index 53c1941..d5724eb 100644 --- a/zotify/playlist.py +++ b/zotify/playlist.py @@ -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)