From 26ed5bef39b1856e6f656af1f1284d6d00426c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 22 Nov 2021 10:31:19 +0100 Subject: [PATCH 01/12] Update README --- README.md | 84 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index aed534e..0d10a01 100644 --- a/README.md +++ b/README.md @@ -37,26 +37,78 @@ Python packages: Basic command line usage: python zspotify Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls. -Extra command line options: - -p, --playlist Downloads a saved playlist from your account +Different usage modes: + (nothing) Download the tracks/alumbs/playlists URLs from the parameter + -d, --download Download all tracks/alumbs/playlists URLs from the specified file + -p, --playlist Downloads a saved playlist from your account -ls, --liked-songs Downloads all the liked songs from your account - -s, --search Loads search prompt to find then download a specific track, album or playlist + -s, --search Loads search prompt to find then download a specific track, album or playlist + +Extra command line options: -ns, --no-splash Suppress the splash screen when loading. - -Options that can be configured in zs_config.json: - ROOT_PATH Change this path if you don't like the default directory where ZSpotify saves the music - ROOT_PODCAST_PATH Change this path if you don't like the default directory where ZSpotify saves the podcasts - - SKIP_EXISTING_FILES Set this to false if you want ZSpotify to overwrite files with the same name rather than skipping the song - - MUSIC_FORMAT Can be "mp3" or "ogg", mp3 is required for track metadata however ogg is slightly higher quality as it is not transcoded. - - FORCE_PREMIUM Set this to true if ZSpotify isn't automatically detecting that you are using a premium account - - ANTI_BAN_WAIT_TIME Change this setting if the time waited between bulk downloads is too high or low - OVERRIDE_AUTO_WAIT Change this to true if you want to completely disable the wait between songs for faster downloads with the risk of instability + --config-location Use a different zs_config.json, defaults to the one in the program directory ``` +### Options: + +All these options can either be configured in the zs_config or via the commandline, in case of both the commandline-option has higher priority. +Be aware you have to set boolean values in the commandline like this: `--download-real-time=True` + +| Key (zs-config) | commandline parameter | Description +|------------------------------|----------------------------------|---------------------------------------------------------------------| +| ROOT_PATH | --root-path | directory where ZSpotify saves the music +| ROOT_PODCAST_PATH | --root-podcast-path | directory where ZSpotify saves the podcasts +| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name +| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Create a .song_archive file and skip previously downloaded songs +| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis) +| FORCE_PREMIUM | --force-premium | Force the use of high quality downloads (only with premium accounts) +| ANTI_BAN_WAIT_TIME | --anti-ban-wait-time | The wait time between bulk downloads +| OVERRIDE_AUTO_WAIT | --override-auto-wait | Totally disable wait time between songs with the risk of instability +| CHUNK_SIZE | --chunk-size | chunk size for downloading +| SPLIT_ALBUM_DISCS | --split-album-discs | split downloaded albums by disc +| DOWNLOAD_REAL_TIME | --download-real-time | only downloads songs as fast as they would be played, can prevent account bans +| LANGUAGE | --language | Language for spotify metadata +| BITRATE | --bitrate | Overwrite the bitrate for ffmpeg encoding +| SONG_ARCHIVE | --song-archive | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED +| CREDENTIALS_LOCATION | --credentials-location | The location of the credentials.json +| OUTPUT | --output | The output location/format (see below) +| PRINT_SPLASH | --print-splash | Print the splash message +| PRINT_SKIPS | --print-skips | Print messages if a song is being skipped +| PRINT_DOWNLOAD_PROGRESS | --print-download-progress | Print the download/playlist progress bars +| PRINT_ERRORS | --print-errors | Print errors +| PRINT_DOWNLOADS | --print-downloads | Print messages when a song is finished downloading + +### Output format: + +With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format. +The value is relative to the `ROOT_PATH`/`ROOT_PODCAST_PATH` directory and can contain the following placeholder: + +| Placeholder | Description +|-----------------|-------------------------------- +| {artist} | The song artist +| {album} | The song album +| {song_name} | The song name +| {release_year} | The song release year +| {disc_number} | The disc number +| {track_number} | The track_number +| {id} | The song id +| {track_id} | The track id +| {ext} | The file extension +| {album_id} | (only when downloading albums) ID of the album +| {album_num} | (only when downloading albums) Incrementing track number +| {playlist} | (only when downloading playlists) Name of the playlist +| {playlist_num} | (only when downloading playlists) Incrementing track number + +Example values could be: +~~~~ +{playlist}/{artist} - {song_name}.{ext} +{playlist}/{playlist_num} - {artist} - {song_name}.{ext} +Liked Songs/{artist} - {song_name}.{ext} +{artist} - {song_name}.{ext} +{artist}/{album}/{album_num} - {artist} - {song_name}.{ext} +/home/user/downloads/{artist} - {song_name} [{id}].{ext} +~~~~ + ### Docker Usage ``` From c42b8131d16b513033dcaf2355dd5f2a352d2d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 22 Nov 2021 14:33:10 +0100 Subject: [PATCH 02/12] Fix credentials.json not being saved in CREDENTIALS_LOCATION path --- zspotify/zspotify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zspotify/zspotify.py b/zspotify/zspotify.py index 51ef6c6..4e91842 100644 --- a/zspotify/zspotify.py +++ b/zspotify/zspotify.py @@ -49,7 +49,8 @@ class ZSpotify: user_name = input('Username: ') password = getpass() try: - cls.SESSION = Session.Builder().user_pass(user_name, password).create() + conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build() + cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create() return except RuntimeError: pass From 0c6ec8a108c6841729cbba9b2a9d2b9d360dfb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 22 Nov 2021 11:41:38 +0100 Subject: [PATCH 03/12] Fix delaytime with DOWNLOAD_REAL_TIME ( fixes #175 ) --- zspotify/track.py | 18 ++++++++++++++---- zspotify/utils.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/zspotify/track.py b/zspotify/track.py index 8f4ef9d..e40c0a8 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -1,3 +1,4 @@ +import math import os import re import time @@ -12,7 +13,7 @@ from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAY RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS from termoutput import Printer, PrintChannel from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \ - get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive + get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds from zspotify import ZSpotify @@ -133,6 +134,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= create_download_directory(filedir) total_size = stream.input_stream.size + time_start = time.time() + downloaded = 0 with open(filename, 'wb') as file, Printer.progress( desc=song_name, total=total_size, @@ -141,18 +144,25 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= unit_divisor=1024, disable=disable_progressbar ) as p_bar: - pause = duration_ms / ZSpotify.CONFIG.get_chunk_size() for chunk in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1): data = stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size()) p_bar.update(file.write(data)) + downloaded += len(data) if ZSpotify.CONFIG.get_download_real_time(): - time.sleep(pause) + delta_real = time.time() - time_start + delta_want = (downloaded / total_size) * (duration_ms/1000) + if delta_want > delta_real: + time.sleep(delta_want - delta_real) + + time_downloaded = time.time() convert_audio_format(filename) set_audio_tags(filename, artists, name, album_name, release_year, disc_number, track_number) set_music_thumbnail(filename, image_url) - Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, os.path.dirname(__file__))}" ###' + "\n") + time_finished = time.time() + + Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, os.path.dirname(__file__))}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###' + "\n") # add song id to archive file if ZSpotify.CONFIG.get_skip_previously_downloaded(): diff --git a/zspotify/utils.py b/zspotify/utils.py index c7da4c3..e421b33 100644 --- a/zspotify/utils.py +++ b/zspotify/utils.py @@ -1,4 +1,5 @@ import datetime +import math import os import platform import re @@ -30,6 +31,7 @@ def create_download_directory(download_path: str) -> None: with open(hidden_file_path, 'w', encoding='utf-8') as f: pass + def get_previously_downloaded() -> List[str]: """ Returns list of all time downloaded songs """ @@ -42,6 +44,7 @@ def get_previously_downloaded() -> List[str]: return ids + def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None: """ Adds song id to all time installed songs archive """ @@ -54,6 +57,7 @@ def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str with open(archive_path, 'w', encoding='utf-8') as file: file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n') + def get_directory_song_ids(download_path: str) -> List[str]: """ Gets song ids of songs in directory """ @@ -66,6 +70,7 @@ def get_directory_song_ids(download_path: str) -> List[str]: return song_ids + def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, author_name: str, song_name: str) -> None: """ Appends song_id to .song_ids file in directory """ @@ -75,6 +80,7 @@ def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, a with open(hidden_file_path, 'a', encoding='utf-8') as file: file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n') + def get_downloaded_song_duration(filename: str) -> float: """ Returns the downloaded file's duration in seconds """ @@ -251,3 +257,26 @@ def fix_filename(name): True """ return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE) + + +def fmt_seconds(secs: float) -> str: + val = math.floor(secs) + + s = math.floor(val % 60) + val -= s + val /= 60 + + m = math.floor(val % 60) + val -= m + val /= 60 + + h = math.floor(val) + + if h == 0 and m == 0 and s == 0: + return "0" + elif h == 0 and m == 0: + return f'{s}'.zfill(2) + elif h == 0: + return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) + else: + return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) From c5886e340114a09c181ca6b1052885176d312be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 22 Nov 2021 14:45:20 +0100 Subject: [PATCH 04/12] Exception traceback in GENERAL DOWNLOAD ERROR --- zspotify/track.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zspotify/track.py b/zspotify/track.py index 8f4ef9d..73197ae 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -14,7 +14,7 @@ from termoutput import Printer, PrintChannel from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \ get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive from zspotify import ZSpotify - +import traceback def get_saved_tracks() -> list: """ Returns user's saved tracks """ @@ -131,7 +131,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= stream = ZSpotify.get_content_stream( track_id, ZSpotify.DOWNLOAD_QUALITY) create_download_directory(filedir) - total_size = stream.input_stream.size + total_size = stream.input_stream.size / "" with open(filename, 'wb') as file, Printer.progress( desc=song_name, @@ -166,6 +166,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= except Exception as e: Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') Printer.print(PrintChannel.ERRORS, str(e) + "\n") + Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") if os.path.exists(filename): os.remove(filename) From cb42b4a87833d64b004042327d62ffefe7554fce Mon Sep 17 00:00:00 2001 From: logykk Date: Wed, 24 Nov 2021 21:22:54 +1300 Subject: [PATCH 05/12] removed pydub dependency --- requirements.txt | 1 - zspotify/app.py | 4 +--- zspotify/playlist.py | 4 +--- zspotify/track.py | 3 +-- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index b1877ec..8707268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,5 @@ git+https://github.com/kokarare1212/librespot-python music_tag Pillow protobuf -pydub tabulate tqdm \ No newline at end of file diff --git a/zspotify/app.py b/zspotify/app.py index 75be315..3bf32fa 100644 --- a/zspotify/app.py +++ b/zspotify/app.py @@ -255,9 +255,7 @@ def search(search_term): selection = '' print('\n> SELECT A DOWNLOAD OPTION BY ID') print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s') - print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s') - print('> For example, typing 5 to get option 5 or 10-20 to get\nevery option from 10-20 (inclusive)\n') - print('> Or type 10,12,15,18 to get those options in particular') + print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n') while len(selection) == 0: selection = str(input('ID(s): ')) inputs = split_input(selection) diff --git a/zspotify/playlist.py b/zspotify/playlist.py index 67ddb33..36cac39 100644 --- a/zspotify/playlist.py +++ b/zspotify/playlist.py @@ -70,9 +70,7 @@ def download_from_user_playlist(): selection = '' print('\n> SELECT A PLAYLIST BY ID') print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s') - print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s') - print('> For example, typing 10 to get one playlist or 10-20 to get\nevery playlist from 10-20 (inclusive)\n') - print('> Or type 10,12,15,18 to get those playlists in particular') + print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n') while len(selection) == 0: selection = str(input('ID(s): ')) playlist_choices = map(int, split_input(selection)) diff --git a/zspotify/track.py b/zspotify/track.py index d7dde3d..fb91b10 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -7,7 +7,6 @@ from typing import Any, Tuple, List from librespot.audio.decoders import AudioQuality from librespot.metadata import TrackId from ffmpy import FFmpeg -from pydub import AudioSegment from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS @@ -132,7 +131,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= stream = ZSpotify.get_content_stream( track_id, ZSpotify.DOWNLOAD_QUALITY) create_download_directory(filedir) - total_size = stream.input_stream.size / "" + total_size = stream.input_stream.size time_start = time.time() downloaded = 0 From 96515801c4fc4b0aa3d3c1bd4f6b43990fc3f4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Wed, 24 Nov 2021 14:40:14 +0100 Subject: [PATCH 06/12] return resolved paths from config.py --- zspotify/config.py | 8 ++++---- zspotify/podcast.py | 6 +----- zspotify/track.py | 4 ++-- zspotify/utils.py | 4 ++-- zspotify/zspotify.py | 2 +- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/zspotify/config.py b/zspotify/config.py index 79f7963..a18ac67 100644 --- a/zspotify/config.py +++ b/zspotify/config.py @@ -129,11 +129,11 @@ class Config: @classmethod def get_root_path(cls) -> str: - return cls.get(ROOT_PATH) + return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PATH)) @classmethod def get_root_podcast_path(cls) -> str: - return cls.get(ROOT_PODCAST_PATH) + return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PODCAST_PATH)) @classmethod def get_skip_existing_files(cls) -> bool: @@ -181,11 +181,11 @@ class Config: @classmethod def get_song_archive(cls) -> str: - return cls.get(SONG_ARCHIVE) + return os.path.join(ZSpotify.CONFIG.get_root_path(), cls.get(SONG_ARCHIVE)) @classmethod def get_credentials_location(cls) -> str: - return cls.get(CREDENTIALS_LOCATION) + return os.path.join(os.getcwd(), cls.get(CREDENTIALS_LOCATION)) @classmethod def get_output(cls, mode: str) -> str: diff --git a/zspotify/podcast.py b/zspotify/podcast.py index 88cd431..e9e6854 100644 --- a/zspotify/podcast.py +++ b/zspotify/podcast.py @@ -77,11 +77,7 @@ def download_episode(episode_id) -> None: direct_download_url = ZSpotify.invoke_url( 'https://api-partner.spotify.com/pathfinder/v1/query?operationName=getEpisode&variables={"uri":"spotify:episode:' + episode_id + '"}&extensions={"persistedQuery":{"version":1,"sha256Hash":"224ba0fd89fcfdfb3a15fa2d82a6112d3f4e2ac88fba5c6713de04d1b72cf482"}}')["data"]["episode"]["audio"]["items"][-1]["url"] - download_directory = os.path.join( - os.path.dirname(__file__), - ZSpotify.CONFIG.get_root_podcast_path(), - extra_paths, - ) + download_directory = os.path.join(ZSpotify.CONFIG.get_root_podcast_path(), extra_paths) download_directory = os.path.realpath(download_directory) create_download_directory(download_directory) diff --git a/zspotify/track.py b/zspotify/track.py index fb91b10..7f94855 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -93,7 +93,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= output_template = output_template.replace("{track_id}", fix_filename(track_id)) output_template = output_template.replace("{ext}", EXT_MAP.get(ZSpotify.CONFIG.get_download_format().lower())) - filename = os.path.join(os.path.dirname(__file__), ZSpotify.CONFIG.get_root_path(), output_template) + filename = os.path.join(ZSpotify.CONFIG.get_root_path(), output_template) filedir = os.path.dirname(filename) check_name = os.path.isfile(filename) and os.path.getsize(filename) @@ -161,7 +161,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= time_finished = time.time() - Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, os.path.dirname(__file__))}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###' + "\n") + Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, ZSpotify.CONFIG.get_root_path())}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###' + "\n") # add song id to archive file if ZSpotify.CONFIG.get_skip_previously_downloaded(): diff --git a/zspotify/utils.py b/zspotify/utils.py index e421b33..a288848 100644 --- a/zspotify/utils.py +++ b/zspotify/utils.py @@ -36,7 +36,7 @@ def get_previously_downloaded() -> List[str]: """ Returns list of all time downloaded songs """ ids = [] - archive_path = os.path.join(os.path.dirname(__file__), ZSpotify.CONFIG.get_root_path(), ZSpotify.CONFIG.get_song_archive()) + archive_path = ZSpotify.CONFIG.get_song_archive() if os.path.exists(archive_path): with open(archive_path, 'r', encoding='utf-8') as f: @@ -48,7 +48,7 @@ def get_previously_downloaded() -> List[str]: def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None: """ Adds song id to all time installed songs archive """ - archive_path = os.path.join(os.path.dirname(__file__), ZSpotify.CONFIG.get_root_path(), ZSpotify.CONFIG.get_song_archive()) + archive_path = ZSpotify.CONFIG.get_song_archive() if os.path.exists(archive_path): with open(archive_path, 'a', encoding='utf-8') as file: diff --git a/zspotify/zspotify.py b/zspotify/zspotify.py index 4e91842..7643a60 100644 --- a/zspotify/zspotify.py +++ b/zspotify/zspotify.py @@ -35,7 +35,7 @@ class ZSpotify: def login(cls): """ Authenticates with Spotify and saves credentials to a file """ - cred_location = os.path.join(os.getcwd(), Config.get_credentials_location()) + cred_location = Config.get_credentials_location() if os.path.isfile(cred_location): try: From 5a655f96ef3e7360b752d1804db8548b9cff3cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Thu, 25 Nov 2021 10:16:32 +0100 Subject: [PATCH 07/12] Added --temp-download-dir option --- README.md | 1 + zspotify/config.py | 8 ++++++++ zspotify/track.py | 27 ++++++++++++++++++--------- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0d10a01..3254905 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Be aware you have to set boolean values in the commandline like this: `--downloa | PRINT_DOWNLOAD_PROGRESS | --print-download-progress | Print the download/playlist progress bars | PRINT_ERRORS | --print-errors | Print errors | PRINT_DOWNLOADS | --print-downloads | Print messages when a song is finished downloading +| TEMP_DOWNLOAD_DIR | --temp-download-dir | Download tracks to a temporary directory first ### Output format: diff --git a/zspotify/config.py b/zspotify/config.py index 79f7963..10b1368 100644 --- a/zspotify/config.py +++ b/zspotify/config.py @@ -27,6 +27,7 @@ PRINT_SKIPS = 'PRINT_SKIPS' PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS' PRINT_ERRORS = 'PRINT_ERRORS' PRINT_DOWNLOADS = 'PRINT_DOWNLOADS' +TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR' CONFIG_VALUES = { ROOT_PATH: { 'default': '../ZSpotify Music/', 'type': str, 'arg': '--root-path' }, @@ -50,6 +51,7 @@ CONFIG_VALUES = { PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' }, + TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' }, } OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}' @@ -187,6 +189,12 @@ class Config: def get_credentials_location(cls) -> str: return cls.get(CREDENTIALS_LOCATION) + @classmethod + def get_temp_download_dir(cls) -> str: + if cls.get(TEMP_DOWNLOAD_DIR) == '': + return '' + return os.path.join(ZSpotify.CONFIG.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR)) + @classmethod def get_output(cls, mode: str) -> str: v = cls.get(OUTPUT) diff --git a/zspotify/track.py b/zspotify/track.py index fb91b10..2e34fad 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -2,6 +2,7 @@ import math import os import re import time +import uuid from typing import Any, Tuple, List from librespot.audio.decoders import AudioQuality @@ -83,6 +84,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= for k in extra_keys: output_template = output_template.replace("{"+k+"}", fix_filename(extra_keys[k])) + ext = EXT_MAP.get(ZSpotify.CONFIG.get_download_format().lower()) + output_template = output_template.replace("{artist}", fix_filename(artists[0])) output_template = output_template.replace("{album}", fix_filename(album_name)) output_template = output_template.replace("{song_name}", fix_filename(name)) @@ -91,11 +94,15 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= output_template = output_template.replace("{track_number}", fix_filename(track_number)) output_template = output_template.replace("{id}", fix_filename(scraped_song_id)) output_template = output_template.replace("{track_id}", fix_filename(track_id)) - output_template = output_template.replace("{ext}", EXT_MAP.get(ZSpotify.CONFIG.get_download_format().lower())) + output_template = output_template.replace("{ext}", ext) filename = os.path.join(os.path.dirname(__file__), ZSpotify.CONFIG.get_root_path(), output_template) filedir = os.path.dirname(filename) + filename_temp = filename + if ZSpotify.CONFIG.get_temp_download_dir() != '': + filename_temp = os.path.join(ZSpotify.CONFIG.get_temp_download_dir(), f'zspotify_{str(uuid.uuid4())}_{track_id}.{ext}') + check_name = os.path.isfile(filename) and os.path.getsize(filename) check_id = scraped_song_id in get_directory_song_ids(filedir) check_all_time = scraped_song_id in get_previously_downloaded() @@ -128,14 +135,13 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= if track_id != scraped_song_id: track_id = scraped_song_id track_id = TrackId.from_base62(track_id) - stream = ZSpotify.get_content_stream( - track_id, ZSpotify.DOWNLOAD_QUALITY) + stream = ZSpotify.get_content_stream(track_id, ZSpotify.DOWNLOAD_QUALITY) create_download_directory(filedir) total_size = stream.input_stream.size time_start = time.time() downloaded = 0 - with open(filename, 'wb') as file, Printer.progress( + with open(filename_temp, 'wb') as file, Printer.progress( desc=song_name, total=total_size, unit='B', @@ -155,9 +161,12 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= time_downloaded = time.time() - convert_audio_format(filename) - set_audio_tags(filename, artists, name, album_name, release_year, disc_number, track_number) - set_music_thumbnail(filename, image_url) + convert_audio_format(filename_temp) + set_audio_tags(filename_temp, artists, name, album_name, release_year, disc_number, track_number) + set_music_thumbnail(filename_temp, image_url) + + if filename_temp != filename: + os.rename(filename_temp, filename) time_finished = time.time() @@ -176,8 +185,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') Printer.print(PrintChannel.ERRORS, str(e) + "\n") Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") - if os.path.exists(filename): - os.remove(filename) + if os.path.exists(filename_temp): + os.remove(filename_temp) def convert_audio_format(filename) -> None: From 41c5ea79c669fb53d673af0de4e4b282f16db329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Thu, 25 Nov 2021 10:25:25 +0100 Subject: [PATCH 08/12] Better error output if get_song_info fails --- zspotify/album.py | 6 +++--- zspotify/playlist.py | 2 +- zspotify/podcast.py | 2 +- zspotify/track.py | 38 +++++++++++++++++++++++--------------- zspotify/zspotify.py | 3 ++- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/zspotify/album.py b/zspotify/album.py index 8aea119..901844a 100644 --- a/zspotify/album.py +++ b/zspotify/album.py @@ -26,18 +26,18 @@ def get_album_tracks(album_id): def get_album_name(album_id): """ Returns album name """ - resp = ZSpotify.invoke_url(f'{ALBUM_URL}/{album_id}') + (raw, resp) = ZSpotify.invoke_url(f'{ALBUM_URL}/{album_id}') return resp[ARTISTS][0][NAME], fix_filename(resp[NAME]) def get_artist_albums(artist_id): """ Returns artist's albums """ - resp = ZSpotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums?include_groups=album%2Csingle') + (raw, resp) = ZSpotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums?include_groups=album%2Csingle') # Return a list each album's id album_ids = [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))] # Recursive requests to get all albums including singles an EPs while resp['next'] is not None: - resp = ZSpotify.invoke_url(resp['next']) + (raw, resp) = ZSpotify.invoke_url(resp['next']) album_ids.extend([resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))]) return album_ids diff --git a/zspotify/playlist.py b/zspotify/playlist.py index 36cac39..03e489a 100644 --- a/zspotify/playlist.py +++ b/zspotify/playlist.py @@ -42,7 +42,7 @@ def get_playlist_songs(playlist_id): def get_playlist_info(playlist_id): """ Returns information scraped from playlist """ - resp = ZSpotify.invoke_url(f'{PLAYLISTS_URL}/{playlist_id}?fields=name,owner(display_name)&market=from_token') + (raw, resp) = ZSpotify.invoke_url(f'{PLAYLISTS_URL}/{playlist_id}?fields=name,owner(display_name)&market=from_token') return resp['name'].strip(), resp['owner']['display_name'].strip() diff --git a/zspotify/podcast.py b/zspotify/podcast.py index 88cd431..032c9b7 100644 --- a/zspotify/podcast.py +++ b/zspotify/podcast.py @@ -14,7 +14,7 @@ SHOWS_URL = 'https://api.spotify.com/v1/shows' def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]: - info = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}') + (raw, info) = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}') if ERROR in info: return None, None return fix_filename(info[SHOW][NAME]), fix_filename(info[NAME]) diff --git a/zspotify/track.py b/zspotify/track.py index fb91b10..78106d1 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -35,27 +35,34 @@ def get_saved_tracks() -> list: def get_song_info(song_id) -> Tuple[List[str], str, str, Any, Any, Any, Any, Any, Any, int]: """ Retrieves metadata for downloaded songs """ - info = ZSpotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token') + (raw, info) = ZSpotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token') - artists = [] - for data in info[TRACKS][0][ARTISTS]: - artists.append(data[NAME]) - album_name = info[TRACKS][0][ALBUM][NAME] - name = info[TRACKS][0][NAME] - image_url = info[TRACKS][0][ALBUM][IMAGES][0][URL] - release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0] - disc_number = info[TRACKS][0][DISC_NUMBER] - track_number = info[TRACKS][0][TRACK_NUMBER] - scraped_song_id = info[TRACKS][0][ID] - is_playable = info[TRACKS][0][IS_PLAYABLE] - duration_ms = info[TRACKS][0][DURATION_MS] + if not TRACKS in info: + raise ValueError(f'Invalid response from TRACKS_URL:\n{raw}') + + try: + artists = [] + for data in info[TRACKS][0][ARTISTS]: + artists.append(data[NAME]) + album_name = info[TRACKS][0][ALBUM][NAME] + name = info[TRACKS][0][NAME] + image_url = info[TRACKS][0][ALBUM][IMAGES][0][URL] + release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0] + disc_number = info[TRACKS][0][DISC_NUMBER] + track_number = info[TRACKS][0][TRACK_NUMBER] + scraped_song_id = info[TRACKS][0][ID] + is_playable = info[TRACKS][0][IS_PLAYABLE] + duration_ms = info[TRACKS][0][DURATION_MS] + + return artists, album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms + except Exception as e: + raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}') - return artists, album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms def get_song_duration(song_id: str) -> float: """ Retrieves duration of song in second as is on spotify """ - resp = ZSpotify.invoke_url(f'{TRACK_STATS_URL}{song_id}') + (raw, resp) = ZSpotify.invoke_url(f'{TRACK_STATS_URL}{song_id}') # get duration in miliseconds ms_duration = resp['duration_ms'] @@ -113,6 +120,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= except Exception as e: Printer.print(PrintChannel.ERRORS, '### SKIPPING SONG - FAILED TO QUERY METADATA ###') Printer.print(PrintChannel.ERRORS, str(e) + "\n") + Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") else: try: if not is_playable: diff --git a/zspotify/zspotify.py b/zspotify/zspotify.py index 4e91842..caad7c5 100644 --- a/zspotify/zspotify.py +++ b/zspotify/zspotify.py @@ -86,7 +86,8 @@ class ZSpotify: @classmethod def invoke_url(cls, url): headers = cls.get_auth_header() - return requests.get(url, headers=headers).json() + response = requests.get(url, headers=headers) + return response.text, response.json() @classmethod def check_premium(cls) -> bool: From a0bebe8f7c447888961766b55e1daa8cb2f8c292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Fri, 26 Nov 2021 08:52:48 +0100 Subject: [PATCH 09/12] Fix weird (?) error when running on other machine --- zspotify/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zspotify/config.py b/zspotify/config.py index d950ec4..e54c91d 100644 --- a/zspotify/config.py +++ b/zspotify/config.py @@ -183,7 +183,7 @@ class Config: @classmethod def get_song_archive(cls) -> str: - return os.path.join(ZSpotify.CONFIG.get_root_path(), cls.get(SONG_ARCHIVE)) + return os.path.join(cls.get_root_path(), cls.get(SONG_ARCHIVE)) @classmethod def get_credentials_location(cls) -> str: @@ -193,7 +193,7 @@ class Config: def get_temp_download_dir(cls) -> str: if cls.get(TEMP_DOWNLOAD_DIR) == '': return '' - return os.path.join(ZSpotify.CONFIG.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR)) + return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR)) @classmethod def get_output(cls, mode: str) -> str: From 925625133f80192da4344ce12c5964f71478a191 Mon Sep 17 00:00:00 2001 From: Matus Koprda Date: Sat, 27 Nov 2021 09:03:21 +0100 Subject: [PATCH 10/12] Fix podcast downloader not expecting podcast data as a tuple --- zspotify/podcast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zspotify/podcast.py b/zspotify/podcast.py index 5ade8d7..8337e01 100644 --- a/zspotify/podcast.py +++ b/zspotify/podcast.py @@ -75,7 +75,7 @@ def download_episode(episode_id) -> None: filename = podcast_name + ' - ' + episode_name direct_download_url = ZSpotify.invoke_url( - 'https://api-partner.spotify.com/pathfinder/v1/query?operationName=getEpisode&variables={"uri":"spotify:episode:' + episode_id + '"}&extensions={"persistedQuery":{"version":1,"sha256Hash":"224ba0fd89fcfdfb3a15fa2d82a6112d3f4e2ac88fba5c6713de04d1b72cf482"}}')["data"]["episode"]["audio"]["items"][-1]["url"] + 'https://api-partner.spotify.com/pathfinder/v1/query?operationName=getEpisode&variables={"uri":"spotify:episode:' + episode_id + '"}&extensions={"persistedQuery":{"version":1,"sha256Hash":"224ba0fd89fcfdfb3a15fa2d82a6112d3f4e2ac88fba5c6713de04d1b72cf482"}}')[1]["data"]["episode"]["audio"]["items"][-1]["url"] download_directory = os.path.join(ZSpotify.CONFIG.get_root_podcast_path(), extra_paths) download_directory = os.path.realpath(download_directory) From daad5e00d8ff02c5a90fc6cf9b7fc081982f3343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sun, 28 Nov 2021 15:12:34 +0100 Subject: [PATCH 11/12] Skip songs with id==None (eg locally added song) --- zspotify/app.py | 7 +++++-- zspotify/podcast.py | 2 +- zspotify/track.py | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/zspotify/app.py b/zspotify/app.py index 3bf32fa..2a5c35b 100644 --- a/zspotify/app.py +++ b/zspotify/app.py @@ -49,7 +49,7 @@ def client(args) -> None: if args.liked_songs: for song in get_saved_tracks(): if not song[TRACK][NAME]: - Printer.print(PrintChannel.ERRORS, '### SKIPPING: SONG DOES NOT EXIST ON SPOTIFY ANYMORE ###' + "\n") + Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ON SPOTIFY ANYMORE ###' + "\n") else: download_track('liked', song[TRACK][ID]) @@ -83,7 +83,10 @@ def download_from_urls(urls: list[str]) -> bool: playlist_songs = get_playlist_songs(playlist_id) name, _ = get_playlist_info(playlist_id) for song in playlist_songs: - download_track('playlist', song[TRACK][ID], extra_keys={'playlist': name}) + if not song[TRACK][NAME]: + Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ON SPOTIFY ANYMORE ###' + "\n") + else: + download_track('playlist', song[TRACK][ID], extra_keys={'playlist': name}) elif episode_id is not None: download = True download_episode(episode_id) diff --git a/zspotify/podcast.py b/zspotify/podcast.py index 8337e01..0519314 100644 --- a/zspotify/podcast.py +++ b/zspotify/podcast.py @@ -70,7 +70,7 @@ def download_episode(episode_id) -> None: extra_paths = podcast_name + '/' if podcast_name is None: - Printer.print(PrintChannel.ERRORS, '### SKIPPING: (EPISODE NOT FOUND) ###') + Printer.print(PrintChannel.SKIPS, '### SKIPPING: (EPISODE NOT FOUND) ###') else: filename = podcast_name + ' - ' + episode_name diff --git a/zspotify/track.py b/zspotify/track.py index 5593af2..022e7f2 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -126,6 +126,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= except Exception as e: Printer.print(PrintChannel.ERRORS, '### SKIPPING SONG - FAILED TO QUERY METADATA ###') + Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id) + "\n") Printer.print(PrintChannel.ERRORS, str(e) + "\n") Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") else: @@ -191,6 +192,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar= time.sleep(ZSpotify.CONFIG.get_anti_ban_wait_time()) except Exception as e: Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') + Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id) + "\n") Printer.print(PrintChannel.ERRORS, str(e) + "\n") Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") if os.path.exists(filename_temp): From a563ca2f13392ddc15f413faac25eac9764b7182 Mon Sep 17 00:00:00 2001 From: logykk Date: Tue, 30 Nov 2021 21:03:45 +1300 Subject: [PATCH 12/12] removed unused imports --- zspotify/app.py | 4 ++-- zspotify/config.py | 2 -- zspotify/playlist.py | 2 +- zspotify/podcast.py | 1 - zspotify/track.py | 1 - zspotify/utils.py | 1 - zspotify/zspotify.py | 4 +--- 7 files changed, 4 insertions(+), 11 deletions(-) diff --git a/zspotify/app.py b/zspotify/app.py index 2a5c35b..c39cd95 100644 --- a/zspotify/app.py +++ b/zspotify/app.py @@ -9,7 +9,7 @@ from playlist import get_playlist_songs, get_playlist_info, download_from_user_p from podcast import download_episode, get_show_episodes from termoutput import Printer, PrintChannel from track import download_track, get_saved_tracks -from utils import fix_filename, splash, split_input, regex_input_for_urls +from utils import splash, split_input, regex_input_for_urls from zspotify import ZSpotify SEARCH_URL = 'https://api.spotify.com/v1/search' @@ -256,7 +256,7 @@ def search(search_term): print('NO RESULTS FOUND - EXITING...') else: selection = '' - print('\n> SELECT A DOWNLOAD OPTION BY ID') + print('> SELECT A DOWNLOAD OPTION BY ID') print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s') print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n') while len(selection) == 0: diff --git a/zspotify/config.py b/zspotify/config.py index e54c91d..0ba7d6d 100644 --- a/zspotify/config.py +++ b/zspotify/config.py @@ -1,8 +1,6 @@ import json import os -import sys from typing import Any -from enum import Enum CONFIG_FILE_PATH = '../zs_config.json' diff --git a/zspotify/playlist.py b/zspotify/playlist.py index 03e489a..65bdcb1 100644 --- a/zspotify/playlist.py +++ b/zspotify/playlist.py @@ -1,7 +1,7 @@ from const import ITEMS, ID, TRACK, NAME from termoutput import Printer from track import download_track -from utils import fix_filename, split_input +from utils import split_input from zspotify import ZSpotify MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists' diff --git a/zspotify/podcast.py b/zspotify/podcast.py index 0519314..93de8b6 100644 --- a/zspotify/podcast.py +++ b/zspotify/podcast.py @@ -1,7 +1,6 @@ import os from typing import Optional, Tuple -from librespot.audio.decoders import VorbisOnlyAudioQuality from librespot.metadata import EpisodeId from const import (ERROR, ID, ITEMS, NAME, SHOW) diff --git a/zspotify/track.py b/zspotify/track.py index 022e7f2..1278412 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -1,4 +1,3 @@ -import math import os import re import time diff --git a/zspotify/utils.py b/zspotify/utils.py index a288848..3656145 100644 --- a/zspotify/utils.py +++ b/zspotify/utils.py @@ -4,7 +4,6 @@ import os import platform import re import subprocess -import time from enum import Enum from typing import List, Tuple diff --git a/zspotify/zspotify.py b/zspotify/zspotify.py index 4660e70..f398d5d 100644 --- a/zspotify/zspotify.py +++ b/zspotify/zspotify.py @@ -6,18 +6,16 @@ It's like youtube-dl, but for Spotify. (Made by Deathmonger/Footsiefat - @doomslayer117:matrix.org) """ -import json import os import os.path from getpass import getpass -from typing import Any import requests from librespot.audio.decoders import VorbisOnlyAudioQuality from librespot.core import Session from const import TYPE, \ - PREMIUM, USER_READ_EMAIL, AUTHORIZATION, OFFSET, LIMIT, \ + PREMIUM, USER_READ_EMAIL, OFFSET, LIMIT, \ PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ from config import Config