diff --git a/README.md b/README.md index aed534e..3254905 100644 --- a/README.md +++ b/README.md @@ -37,26 +37,79 @@ 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 +| TEMP_DOWNLOAD_DIR | --temp-download-dir | Download tracks to a temporary directory first + +### 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 ``` 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/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/app.py b/zspotify/app.py index 69581ab..bb17bc7 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' @@ -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]) @@ -85,8 +85,11 @@ def download_from_urls(urls: list[str]) -> bool: enum = 1 char_num = len(str(len(playlist_songs))) for song in playlist_songs: - download_track('playlist', song[TRACK][ID], extra_keys={'playlist': name, 'playlist_num': str(enum).zfill(char_num)}) - enum += 1 + 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, 'playlist_num': str(enum).zfill(char_num)}) + enum += 1 elif episode_id is not None: download = True download_episode(episode_id) @@ -256,11 +259,9 @@ 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') - 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/config.py b/zspotify/config.py index 79f7963..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' @@ -27,6 +25,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 +49,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}' @@ -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,17 @@ class Config: @classmethod def get_song_archive(cls) -> str: - return cls.get(SONG_ARCHIVE) + return os.path.join(cls.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_temp_download_dir(cls) -> str: + if cls.get(TEMP_DOWNLOAD_DIR) == '': + return '' + return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR)) @classmethod def get_output(cls, mode: str) -> str: diff --git a/zspotify/playlist.py b/zspotify/playlist.py index 67ddb33..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' @@ -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() @@ -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/podcast.py b/zspotify/podcast.py index 88cd431..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) @@ -14,7 +13,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]) @@ -70,18 +69,14 @@ 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 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( - 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 8f4ef9d..1278412 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -1,20 +1,20 @@ import os import re import time +import uuid 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 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 - +import traceback def get_saved_tracks() -> list: """ Returns user's saved tracks """ @@ -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'] @@ -83,6 +90,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 +100,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) + filename = os.path.join(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() @@ -112,7 +125,9 @@ 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: try: if not is_playable: @@ -128,12 +143,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 - with open(filename, 'wb') as file, Printer.progress( + time_start = time.time() + downloaded = 0 + with open(filename_temp, 'wb') as file, Printer.progress( desc=song_name, total=total_size, unit='B', @@ -141,18 +157,28 @@ 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) - convert_audio_format(filename) - set_audio_tags(filename, artists, name, album_name, release_year, disc_number, track_number) - set_music_thumbnail(filename, image_url) + time_downloaded = time.time() - Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, os.path.dirname(__file__))}" ###' + "\n") + 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() + + 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(): @@ -165,9 +191,11 @@ 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") - if os.path.exists(filename): - os.remove(filename) + Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") + if os.path.exists(filename_temp): + os.remove(filename_temp) def convert_audio_format(filename) -> None: diff --git a/zspotify/utils.py b/zspotify/utils.py index c7da4c3..3656145 100644 --- a/zspotify/utils.py +++ b/zspotify/utils.py @@ -1,9 +1,9 @@ import datetime +import math import os import platform import re import subprocess -import time from enum import Enum from typing import List, Tuple @@ -30,11 +30,12 @@ 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 """ 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: @@ -42,10 +43,11 @@ 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 """ - 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: @@ -54,6 +56,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 +69,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 +79,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 +256,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) diff --git a/zspotify/zspotify.py b/zspotify/zspotify.py index 51ef6c6..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 @@ -35,7 +33,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: @@ -49,7 +47,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 @@ -85,7 +84,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: