From 9a64fa8b394986385d57d4e37701fde0af269c23 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 19 Dec 2025 01:49:51 +0100 Subject: [PATCH] Push V0.2.3 --- zotify/__pycache__/album.cpython-314.pyc | Bin 4850 -> 5423 bytes zotify/__pycache__/app.cpython-314.pyc | Bin 14478 -> 15487 bytes zotify/__pycache__/playlist.cpython-314.pyc | Bin 4915 -> 5437 bytes zotify/__pycache__/track.cpython-314.pyc | Bin 36225 -> 36690 bytes zotify/album.py | 21 +- zotify/app.py | 34 +- zotify/config.py | 2 +- zotify/playlist.py | 24 +- zotify/track.py | 527 ++++++++++---------- 9 files changed, 336 insertions(+), 272 deletions(-) diff --git a/zotify/__pycache__/album.cpython-314.pyc b/zotify/__pycache__/album.cpython-314.pyc index 62df1d06798ccb4931a7bb96ad7288f6fb62e65d..59d5ada185cb424c8410643016794285a65d6255 100644 GIT binary patch delta 1417 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 delta 1086 zcmaJ=T}V?=96x89cOUNCeca7EC%Kfhwdv+(_9bnSnJKrpC|%62X@=VM&cK8|Kp!;7 zmqVgp;6s7Ys+9>n2od!ZJp>~dL&Armm!Jhkk97{)Rg366{LkA%|zQ9xcx~a4TA8&%6pb}cX6R~{mUfJEQEP;t?tU+H)E_11ZkXR@{4j4gH z)~rA_^O79KuA*n=`p#}BXn7q8Ocrm*?HKLoICVA5Y5(2@9jC@2VRH=tf}S@?zMP&n zqw5y8T;E|ui&jsd4YD%9z!@s?jDb1;7{D1itdh&_&eQ0<%5DF*&53O^I&M`KGLlC} z393|EkN^1#ph*?HR1~K3PPFB*pq_&ob6bp; zODR}NGEuA&Lz=J{tktlp-HCBpDSU?)Z5A;hR=sQd-#A$fSHKoZDQ*NQR$G_P?FO8- z8~S(d0?%0+g{}1fs6h#yeCu1>B^ASyX8womGi zQ%Y=9b)V?+m8oRI z2D^S_i>XX{HzMo7G*iB+NHYf#Cz6VXEl=7Xw7)o+@<-E*KhgiZEIF16MN@(1G~-=u zOf$ixV(76qnB zy)#&3luhEqaC{=}m5W4~DQFt$!(H9NIIj|C(AUZ{stnaD2(hsNbgXKFx=sA&5e$vO xS#&v61Lx5F&?`8%HWZ#z!3DI{=-0f&9P&luJ;-*r%YeWC_8=5wFenDF_BWOj)c^nh diff --git a/zotify/__pycache__/playlist.cpython-314.pyc b/zotify/__pycache__/playlist.cpython-314.pyc index fe457d99987ec6a95665f5d8de54b7d95a03deea..3065328242be987c18fb223fab178fc34128ec77 100644 GIT binary patch delta 1588 zcmZ8hOKclO7@qNZ-@A?QRO$sJ4y}41Js`@h;sAz5s{W)w;-Aln~_g#BS}|FMm*!}N30Ev=ZFg&1m$ke*YphZJm_ zSGhwRr}E~9LcC2l#0#(A1Aqrdz;lTQaN-R~@{#P8XsKDxMnzT`2`Da=##pDaPi?o# zJ+(bu@@Yq94=yzr0Yyg;ca6$|nAdzFT!mqCMU1XmLb|Y!&lOVXOxj}gxvZvT(#tOj zkw^qzcdcAP;}h}tIc2&x^h_N>LF|8746TH!+d}pvgHQow(xGJ*JHT#)SRLxXN<^*z zMF@~`LIDD97Nf;!sBjr&bRF^Lk7C=ZMQfzq5l0r~gfcd$^pB4xherp-ViM{iEUV>` z`P6*If=P6O5H2m3TFPa0BdHhimvz*MNz_fC$JSTV!mg8-Qpj><^2K?J!9j~TLzl3` zu5VJ~A-gikCQt3O$(nOCfcaN&*FS*fl=Q}#x((MuDY({O_ITGWlwEaegEs6*zDH8a zFH+0a%N((j-=i(%Si7-3{x`Jx|aqN$sOIGq>8y?$)ivuN|MS?DKTMwJ(8i z+ksE;3hT_iA4uMfi#uYtEcv&>*T=41D9gT$r5!m`mV6uUULUzO_FrH|rQ!Kc6D9`HK9XPOB{8H}@- zthT#z@+oSF)Gg{G&`W?J5Ff*+LQXUFKtV zA8auZc6)nQD@7tL7mV!NOD7GKnw>-BrLbyL(a7d>JAfru zRh6OgF4kkM{a7J8WxGD@awRv7NX)MQA^}k|28%m8Uq}~onOD&yoC$9;!U&8! zapb^sl)!S-3<=sCjbp;mIkr1-jvbD{F}ZTh)@i6rgIf=(aQoM%l?4ojd6mx{STmPuBP%Sl~26-eTKLFP&2+)T+ z0m>gk6c01;csyD{&Cnv-rJl*x#(lO zs2R}%erIOluW{xeUw(_Qb@S9<@_~{2*~qQV-Lmfq-=Ew#I`h-}^Y@MFg*P_i#)a{R ziO$R7V>Qj*vx>_%Dqru}un%tZk8LJ;R?pvzt$o_kq%Nyvkevg6Cv{D4rHN)ePG=q|Z~oofmRDF+L*xAa^6$GRF!h zw?*b!)L1)4?ku+r2*)-ROgzR_F**1Of=sNq-$V7hMzU@pjV2-QQ2jrnG+dIV1KEe@xDh$IMU d36#=DWO#!N|3(Iw)@hq*+VON>njVNw{|6$1^#%X{ diff --git a/zotify/__pycache__/track.cpython-314.pyc b/zotify/__pycache__/track.cpython-314.pyc index bb2d3ba3bac85d1528c0aeb89d5cbf95ef7c19ae..31114db00c031c4ea2532585ca7cec18891a37ea 100644 GIT binary patch delta 12092 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 delta 11824 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 p=ST%)9Pf0J_`}P)R`}^ZT diff --git a/zotify/album.py b/zotify/album.py index 9ede74a..16f3f8d 100644 --- a/zotify/album.py +++ b/zotify/album.py @@ -1,5 +1,5 @@ from zotify.const import ITEMS, ARTISTS, NAME, ID -from zotify.termoutput import Printer +from zotify.termoutput import Printer, PrintChannel from zotify.track import download_track from zotify.utils import fix_filename from zotify.zotify import Zotify @@ -71,9 +71,13 @@ def download_album(album): } album_multi_disc = len(disc_numbers) > 1 + downloaded = 0 + skipped = 0 + errored = 0 + for n, track in Printer.progress(enumerate(tracks, start=1), unit_scale=True, unit='Song', total=len(tracks)): # Only pass dynamic numbering and album_id (useful for custom templates using {album_id}). - download_track( + result = download_track( 'album', track[ID], extra_keys={ @@ -85,6 +89,19 @@ def download_album(album): disable_progressbar=True ) + if result == 'downloaded': + downloaded += 1 + elif result == 'skipped': + skipped += 1 + else: + errored += 1 + + total = len(tracks) + Printer.print( + PrintChannel.PROGRESS_INFO, + f'\n#######################################\nFinished! Here is your album summary :\ndownloaded {downloaded}/{total} | skipped {skipped}/{total} | errored {errored}/{total}\n#######################################\n' + ) + def download_artist_albums(artist): """ Downloads albums of an artist """ diff --git a/zotify/app.py b/zotify/app.py index b103829..61c5231 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -103,8 +103,19 @@ def download_from_urls(urls: list[str]) -> bool: download = True playlist_songs = get_playlist_songs(playlist_id) name, _ = get_playlist_info(playlist_id) - enum = 1 - char_num = len(str(len(playlist_songs))) + track_items = [] + for song in playlist_songs: + track_obj = song.get(TRACK) if isinstance(song, dict) else None + if track_obj and track_obj.get(ID) and track_obj.get(TYPE) != "episode": + track_items.append(song) + + expected_total = len(track_items) + downloaded_count = 0 + skipped_count = 0 + errored_count = 0 + + track_enum = 1 + char_num = len(str(expected_total if expected_total else 1)) for song in playlist_songs: track_obj = song.get(TRACK) if isinstance(song, dict) else None if not track_obj or not track_obj.get(NAME) or not track_obj.get(ID): @@ -113,16 +124,27 @@ def download_from_urls(urls: list[str]) -> bool: if track_obj.get(TYPE) == "episode": # Playlist item is a podcast episode download_episode(track_obj[ID]) else: - download_track('playlist', track_obj[ID], extra_keys= + result = download_track('playlist', track_obj[ID], extra_keys= { 'playlist_song_name': track_obj[NAME], 'playlist': name, - 'playlist_num': str(enum).zfill(char_num), - 'playlist_total': str(len(playlist_songs)), + 'playlist_num': str(track_enum).zfill(char_num), + 'playlist_total': str(expected_total), 'playlist_id': playlist_id, 'playlist_track_id': track_obj[ID] }) - enum += 1 + track_enum += 1 + if result == 'downloaded': + downloaded_count += 1 + elif result == 'skipped': + skipped_count += 1 + else: + errored_count += 1 + + Printer.print( + PrintChannel.PROGRESS_INFO, + f'\n### PLAYLIST SUMMARY: downloaded {downloaded_count}/{expected_total} | skipped {skipped_count}/{expected_total} | errored {errored_count}/{expected_total} ###\n' + ) elif episode_id is not None: download = True download_episode(episode_id) diff --git a/zotify/config.py b/zotify/config.py index e2487d6..ec20213 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -3,7 +3,7 @@ import sys from pathlib import Path, PurePath from typing import Any -ZOTIFY_VERSION = "0.2.1" +ZOTIFY_VERSION = "0.2.2" ROOT_PATH = 'ROOT_PATH' ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH' SKIP_EXISTING = 'SKIP_EXISTING' diff --git a/zotify/playlist.py b/zotify/playlist.py index 043aebf..dd456b7 100644 --- a/zotify/playlist.py +++ b/zotify/playlist.py @@ -1,5 +1,5 @@ from zotify.const import ITEMS, ID, TRACK, NAME -from zotify.termoutput import Printer +from zotify.termoutput import Printer, PrintChannel from zotify.track import download_track from zotify.utils import split_input from zotify.zotify import Zotify @@ -60,24 +60,40 @@ def download_playlist(playlist): pl_name, _ = get_playlist_info(playlist[ID]) playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK] is not None and song[TRACK][ID]] - p_bar = Printer.progress(playlist_songs, unit='song', total=len(playlist_songs), unit_scale=True) + total = len(playlist_songs) + downloaded = 0 + skipped = 0 + errored = 0 + + p_bar = Printer.progress(playlist_songs, unit='song', total=total, unit_scale=True) enum = 1 for song in p_bar: # Use localized playlist name; track metadata (artist/title) is localized in download_track via locale - download_track( + result = download_track( 'extplaylist', song[TRACK][ID], extra_keys={ 'playlist': pl_name, 'playlist_num': str(enum).zfill(2), - 'playlist_total': str(len(playlist_songs)), + 'playlist_total': str(total), 'playlist_id': playlist[ID], }, disable_progressbar=True ) + if result == 'downloaded': + downloaded += 1 + elif result == 'skipped': + skipped += 1 + else: + errored += 1 p_bar.set_description(song[TRACK][NAME]) enum += 1 + Printer.print( + PrintChannel.PROGRESS_INFO, + f'\n#######################################\nFinished! Here is your playlist summary :\ndownloaded {downloaded}/{total} | skipped {skipped}/{total} | errored {errored}/{total}\n#######################################\n' + ) + def download_from_user_playlist(): """ Select which playlist(s) to download """ diff --git a/zotify/track.py b/zotify/track.py index 08c432f..53e7ba2 100644 --- a/zotify/track.py +++ b/zotify/track.py @@ -1,25 +1,29 @@ from pathlib import Path, PurePath -from typing import Any, Tuple, List, Optional +from typing import Any, Tuple, List, Optional, Literal + +import json +import math +import re +import time +import traceback +import uuid + +import ffmpy from librespot.metadata import TrackId + from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ RELEASE_DATE, ID, TRACKS_URL, FOLLOWED_ARTISTS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, \ - HREF, ARTISTS, WIDTH + HREF, WIDTH +from zotify.loader import Loader from zotify.termoutput import Printer, PrintChannel from zotify.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, fmt_seconds from zotify.zotify import Zotify -import traceback -from zotify.loader import Loader -import math -import re -import time -import uuid -import json -import ffmpy -# Track whether we've already applied the OGG delay for bulk (album/playlist) downloads _ogg_delay_applied_once = False +DownloadTrackResult = Literal['downloaded', 'skipped', 'errored'] + def get_saved_tracks() -> list: songs = [] offset = 0 @@ -79,11 +83,9 @@ def ensure_spoticlub_credentials() -> None: spoticlub_user = data.get('spoticlub_user') or '' spoticlub_password = data.get('spoticlub_password') or '' - # If any credential value is missing, prompt the user if not spoticlub_user or not spoticlub_password: Printer.print(PrintChannel.PROGRESS_INFO, '\nSpotiClub credentials not found. Please enter them now.') spoticlub_user = input('SpotiClub username: ').strip() - # Basic loop to avoid empty submissions while not spoticlub_user: spoticlub_user = input('SpotiClub username (cannot be empty): ').strip() @@ -163,7 +165,6 @@ def get_song_genres(rawartists: List[str], track_name: str) -> List[str]: elif artist_genres: genres.append(artist_genres[0]) - # De-duplicate while preserving order seen = set() genres = [g for g in genres if not (g in seen or seen.add(g))] @@ -238,284 +239,290 @@ def get_song_duration(song_id: str) -> float: return duration -def download_track(mode: str, track_id: str, extra_keys=None, disable_progressbar=False) -> None: +def download_track(mode: str, track_id: str, extra_keys=None, disable_progressbar=False) -> DownloadTrackResult: if extra_keys is None: extra_keys = {} - # Ensure SpotiClub credentials exist before starting any download prompts or loaders ensure_spoticlub_credentials() prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...") prepare_download_loader.start() try: - output_template = str(Zotify.CONFIG.get_output(mode)) + try: + output_template = str(Zotify.CONFIG.get_output(mode)) + prepare_download_loader.stop() - prepare_download_loader.stop() + (artists, raw_artists, album_name, name, image_url, release_year, disc_number, + track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id) - (artists, raw_artists, album_name, name, image_url, release_year, disc_number, - track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id) + song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name) - song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name) + for k in extra_keys: + output_template = output_template.replace("{" + k + "}", fix_filename(extra_keys[k])) - for k in extra_keys: - output_template = output_template.replace("{"+k+"}", fix_filename(extra_keys[k])) + ext = EXT_MAP.get(Zotify.CONFIG.get_download_format().lower()) - ext = EXT_MAP.get(Zotify.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)) + output_template = output_template.replace("{release_year}", fix_filename(release_year)) + output_template = output_template.replace("{disc_number}", fix_filename(disc_number)) + 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) + if mode == 'album' and Zotify.CONFIG.get_split_album_discs(): + flag_raw = extra_keys.get('album_multi_disc') + flag = str(flag_raw).strip().lower() in ('1', 'true', 'yes') + try: + disc_number_int = int(disc_number) + except Exception: + disc_number_int = 1 - 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)) - output_template = output_template.replace("{release_year}", fix_filename(release_year)) - output_template = output_template.replace("{disc_number}", fix_filename(disc_number)) - 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) + should_create_disc_dir = flag or disc_number_int > 1 + if should_create_disc_dir: + tpl_path = PurePath(output_template) + disc_dir_name = f"Disc {disc_number_int}" + if disc_dir_name not in tpl_path.parts: + output_template = str(tpl_path.parent / disc_dir_name / tpl_path.name) - # SPLIT_ALBUM_DISCS should only create a Disc folder when the album truly has multiple discs. - # - When downloading via zotify.album.download_album(), we pass extra_keys['album_multi_disc']. - # - As a fallback, any track with disc_number > 1 implies multi-disc. - if mode == 'album' and Zotify.CONFIG.get_split_album_discs(): - flag_raw = extra_keys.get('album_multi_disc') - flag = str(flag_raw).strip().lower() in ('1', 'true', 'yes') - try: - disc_number_int = int(disc_number) - except Exception: - disc_number_int = 1 + filename = PurePath(Zotify.CONFIG.get_root_path()).joinpath(output_template) + filedir = PurePath(filename).parent - should_create_disc_dir = flag or disc_number_int > 1 - if should_create_disc_dir: - tpl_path = PurePath(output_template) - disc_dir_name = f"Disc {disc_number_int}" - if disc_dir_name not in tpl_path.parts: - output_template = str(tpl_path.parent / disc_dir_name / tpl_path.name) + filename_temp = filename + if Zotify.CONFIG.get_temp_download_dir() != '': + filename_temp = PurePath(Zotify.CONFIG.get_temp_download_dir()).joinpath( + f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}' + ) - filename = PurePath(Zotify.CONFIG.get_root_path()).joinpath(output_template) - filedir = PurePath(filename).parent + check_name = Path(filename).is_file() and Path(filename).stat().st_size + check_id = scraped_song_id in get_directory_song_ids(filedir) + check_all_time = scraped_song_id in get_previously_downloaded() - filename_temp = filename - if Zotify.CONFIG.get_temp_download_dir() != '': - filename_temp = PurePath(Zotify.CONFIG.get_temp_download_dir()).joinpath(f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}') + if not check_id and check_name: + stem = PurePath(filename).stem + ext_existing = PurePath(filename).suffix + base_prefix = str(PurePath(filedir).joinpath(stem)) + c = len([ + file + for file in Path(filedir).iterdir() + if str(file).startswith(base_prefix + "_") + ]) + 1 - check_name = Path(filename).is_file() and Path(filename).stat().st_size - check_id = scraped_song_id in get_directory_song_ids(filedir) - check_all_time = scraped_song_id in get_previously_downloaded() + filename = PurePath(filedir).joinpath(f'{stem}_{c}{ext_existing}') - # a song with the same name is installed - if not check_id and check_name: - stem = PurePath(filename).stem - ext = PurePath(filename).suffix - base_prefix = str(PurePath(filedir).joinpath(stem)) - c = len([ - file - for file in Path(filedir).iterdir() - if str(file).startswith(base_prefix + "_") - ]) + 1 + except Exception as e: + prepare_download_loader.stop() + Printer.print(PrintChannel.ERRORS, '### SKIPPING SONG - FAILED TO QUERY METADATA ###') + Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id)) + for k in extra_keys: + Printer.print(PrintChannel.ERRORS, k + ': ' + str(extra_keys[k])) + Printer.print(PrintChannel.ERRORS, "\n") + Printer.print(PrintChannel.ERRORS, str(e) + "\n") + Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") + Printer.print(PrintChannel.PROGRESS_INFO, "Waiting to query Spotify API again.." + "\n") + time.sleep(10) + return download_track(mode, track_id, extra_keys) - # SpotiClub: Fix phantom files when colliding with existing names (-_.mp3) - filename = PurePath(filedir).joinpath(f'{stem}_{c}{ext}') - - - except Exception as e: - Printer.print(PrintChannel.ERRORS, '### SKIPPING SONG - FAILED TO QUERY METADATA ###') - Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id)) - for k in extra_keys: - Printer.print(PrintChannel.ERRORS, k + ': ' + str(extra_keys[k])) - Printer.print(PrintChannel.ERRORS, "\n") - Printer.print(PrintChannel.ERRORS, str(e) + "\n") - Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") - Printer.print(PrintChannel.PROGRESS_INFO, "Waiting to query Spotify API again.." + "\n") - time.sleep(10) - return download_track(mode, track_id, extra_keys) - - else: try: if not is_playable: prepare_download_loader.stop() Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG IS UNAVAILABLE) ###' + "\n") - else: - if check_name and Zotify.CONFIG.get_skip_existing(): - prepare_download_loader.stop() - Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n") - if Zotify.CONFIG.get_always_check_lyrics() and Zotify.CONFIG.get_download_lyrics(): - try: - lyr_dir = Zotify.CONFIG.get_lyrics_location() or PurePath(filename).parent - lyr_name_tpl = Zotify.CONFIG.get_lyrics_filename() - lyr_name = lyr_name_tpl - lyr_name = lyr_name.replace('{artist}', fix_filename(artists[0])) - lyr_name = lyr_name.replace('{song_name}', fix_filename(name)) - lyr_name = lyr_name.replace('{album}', fix_filename(album_name)) - if Zotify.CONFIG.get_unique_lyrics_file(): - lrc_path = PurePath(lyr_dir).joinpath("lyrics.lrc") - else: - lrc_path = PurePath(lyr_dir).joinpath(f"{lyr_name}.lrc") - get_song_lyrics(track_id, lrc_path, title=name, artists=artists, album=album_name, duration_ms=duration_ms) - except ValueError: - Printer.print(PrintChannel.SKIPS, f"### LYRICS_UNAVAILABLE: Lyrics for {song_name} not available or API returned empty/errored response ###") + return 'skipped' - elif check_all_time and Zotify.CONFIG.get_skip_previously_downloaded(): - prepare_download_loader.stop() - Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY DOWNLOADED ONCE) ###' + "\n") - if Zotify.CONFIG.get_always_check_lyrics() and Zotify.CONFIG.get_download_lyrics(): - try: - lyr_dir = Zotify.CONFIG.get_lyrics_location() or PurePath(filename).parent - lyr_name_tpl = Zotify.CONFIG.get_lyrics_filename() - lyr_name = lyr_name_tpl - lyr_name = lyr_name.replace('{artist}', fix_filename(artists[0])) - lyr_name = lyr_name.replace('{song_name}', fix_filename(name)) - lyr_name = lyr_name.replace('{album}', fix_filename(album_name)) - lrc_path = PurePath(lyr_dir).joinpath(f"{lyr_name}.lrc") - get_song_lyrics(track_id, lrc_path, title=name, artists=artists, album=album_name, duration_ms=duration_ms) - except ValueError: - Printer.print(PrintChannel.SKIPS, f"### LYRICS_UNAVAILABLE: Lyrics for {song_name} not available or API returned empty/errored response ###") - - else: - prog_prefix = '' - if mode == 'album': - cur = extra_keys.get('album_num') - total = extra_keys.get('album_total') - - if cur and not total: - # No info about total tracks? Let's query the album from Spotify's API in last resort - try: - album_id = extra_keys.get('album_id') - if album_id: - locale = Zotify.CONFIG.get_locale() - resp = Zotify.invoke_url_with_params( - f'https://api.spotify.com/v1/albums/{album_id}/tracks', - limit=1, - offset=0, - market='from_token', - locale=locale, - ) - total_val = resp.get('total') if isinstance(resp, dict) else None - if total_val is not None: - total = str(total_val) - except Exception: - total = total - - if cur and total: - prog_prefix = f'({cur}/{total}) ' - elif mode in ('playlist', 'extplaylist'): - cur = extra_keys.get('playlist_num') - total = extra_keys.get('playlist_total') - - if cur and not total: - # Same fallback for total tracks in playlist - try: - playlist_id = extra_keys.get('playlist_id') - if playlist_id: - locale = Zotify.CONFIG.get_locale() - resp = Zotify.invoke_url_with_params( - f'https://api.spotify.com/v1/playlists/{playlist_id}/tracks', - limit=1, - offset=0, - market='from_token', - locale=locale, - ) - total_val = resp.get('total') if isinstance(resp, dict) else None - if total_val is not None: - total = str(total_val) - except Exception: - total = total - - if cur and total: - prog_prefix = f'({cur}/{total}) ' - - Printer.print( - PrintChannel.PROGRESS_INFO, - f'\n### {prog_prefix}STARTING "{song_name}" ###\n' - ) - if ext == 'ogg': - # SpotiClub : TEMP? : For albums/playlists, wait 5 seconds between OGG tracks to avoid - # spamming the SpotiClub API for audio keys. - # Skip the very first track in the run and for single-track downloads. - global _ogg_delay_applied_once - if mode in ('album', 'playlist', 'extplaylist'): - if _ogg_delay_applied_once: - # Spammy log - # Printer.print(PrintChannel.PROGRESS_INFO, '\n## OGG File : Waiting 5 seconds before resuming... ##') - time.sleep(5) - else: - _ogg_delay_applied_once = True - if track_id != scraped_song_id: - track_id = scraped_song_id - track = TrackId.from_base62(track_id) - stream = Zotify.get_content_stream(track, Zotify.DOWNLOAD_QUALITY) - create_download_directory(filedir) - total_size = stream.input_stream.size - - prepare_download_loader.stop() - - time_start = time.time() - downloaded = 0 - with open(filename_temp, 'wb') as file, Printer.progress( - desc=song_name, - total=total_size, - unit='B', - unit_scale=True, - unit_divisor=1024, - disable=disable_progressbar - ) as p_bar: - b = 0 - while b < 5: - data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size()) - p_bar.update(file.write(data)) - downloaded += len(data) - b += 1 if data == b'' else 0 - if Zotify.CONFIG.get_download_real_time(): - 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() - - genres = get_song_genres(raw_artists, name) - - lyrics_lines: Optional[List[str]] = None - if Zotify.CONFIG.get_download_lyrics(): - try: - lyr_dir = Zotify.CONFIG.get_lyrics_location() or PurePath(filename).parent - lyr_name_tpl = Zotify.CONFIG.get_lyrics_filename() - lyr_name = lyr_name_tpl - lyr_name = lyr_name.replace('{artist}', fix_filename(artists[0])) - lyr_name = lyr_name.replace('{song_name}', fix_filename(name)) - lyr_name = lyr_name.replace('{album}', fix_filename(album_name)) - if Zotify.CONFIG.get_unique_lyrics_file(): - lrc_path = PurePath(lyr_dir).joinpath("lyrics.lrc") - else: - lrc_path = PurePath(lyr_dir).joinpath(f"{lyr_name}.lrc") - lyrics_lines = get_song_lyrics( - track_id, lrc_path, title=name, artists=artists, album=album_name, duration_ms=duration_ms, write_file=True - ) - except ValueError: - Printer.print(PrintChannel.SKIPS, f"### LYRICS_UNAVAILABLE: Lyrics for {song_name} not available or API returned empty/errored response ###") - convert_audio_format(filename_temp) + if check_name and Zotify.CONFIG.get_skip_existing(): + prepare_download_loader.stop() + Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n") + if Zotify.CONFIG.get_always_check_lyrics() and Zotify.CONFIG.get_download_lyrics(): try: - set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number, - lyrics_lines) - set_music_thumbnail(filename_temp, image_url) + lyr_dir = Zotify.CONFIG.get_lyrics_location() or PurePath(filename).parent + lyr_name_tpl = Zotify.CONFIG.get_lyrics_filename() + lyr_name = lyr_name_tpl + lyr_name = lyr_name.replace('{artist}', fix_filename(artists[0])) + lyr_name = lyr_name.replace('{song_name}', fix_filename(name)) + lyr_name = lyr_name.replace('{album}', fix_filename(album_name)) + if Zotify.CONFIG.get_unique_lyrics_file(): + lrc_path = PurePath(lyr_dir).joinpath("lyrics.lrc") + else: + lrc_path = PurePath(lyr_dir).joinpath(f"{lyr_name}.lrc") + get_song_lyrics(track_id, lrc_path, title=name, artists=artists, album=album_name, duration_ms=duration_ms) + except ValueError: + Printer.print(PrintChannel.SKIPS, f"### LYRICS_UNAVAILABLE: Lyrics for {song_name} not available or API returned empty/errored response ###") + return 'skipped' + + if check_all_time and Zotify.CONFIG.get_skip_previously_downloaded(): + prepare_download_loader.stop() + Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY DOWNLOADED ONCE) ###' + "\n") + if Zotify.CONFIG.get_always_check_lyrics() and Zotify.CONFIG.get_download_lyrics(): + try: + lyr_dir = Zotify.CONFIG.get_lyrics_location() or PurePath(filename).parent + lyr_name_tpl = Zotify.CONFIG.get_lyrics_filename() + lyr_name = lyr_name_tpl + lyr_name = lyr_name.replace('{artist}', fix_filename(artists[0])) + lyr_name = lyr_name.replace('{song_name}', fix_filename(name)) + lyr_name = lyr_name.replace('{album}', fix_filename(album_name)) + lrc_path = PurePath(lyr_dir).joinpath(f"{lyr_name}.lrc") + get_song_lyrics(track_id, lrc_path, title=name, artists=artists, album=album_name, duration_ms=duration_ms) + except ValueError: + Printer.print(PrintChannel.SKIPS, f"### LYRICS_UNAVAILABLE: Lyrics for {song_name} not available or API returned empty/errored response ###") + return 'skipped' + + prog_prefix = '' + if mode == 'album': + cur = extra_keys.get('album_num') + total = extra_keys.get('album_total') + if cur and not total: + try: + album_id = extra_keys.get('album_id') + if album_id: + locale = Zotify.CONFIG.get_locale() + resp = Zotify.invoke_url_with_params( + f'https://api.spotify.com/v1/albums/{album_id}/tracks', + limit=1, + offset=0, + market='from_token', + locale=locale, + ) + total_val = resp.get('total') if isinstance(resp, dict) else None + if total_val is not None: + total = str(total_val) except Exception: - Printer.print(PrintChannel.ERRORS, "Unable to write metadata, ensure ffmpeg is installed and added to your PATH.") + total = total + if cur and total: + prog_prefix = f'({cur}/{total}) ' + elif mode in ('playlist', 'extplaylist'): + cur = extra_keys.get('playlist_num') + total = extra_keys.get('playlist_total') + if cur and not total: + try: + playlist_id = extra_keys.get('playlist_id') + if playlist_id: + locale = Zotify.CONFIG.get_locale() + resp = Zotify.invoke_url_with_params( + f'https://api.spotify.com/v1/playlists/{playlist_id}/tracks', + limit=1, + offset=0, + market='from_token', + locale=locale, + ) + total_val = resp.get('total') if isinstance(resp, dict) else None + if total_val is not None: + total = str(total_val) + except Exception: + total = total + if cur and total: + prog_prefix = f'({cur}/{total}) ' - if filename_temp != filename: - Path(filename_temp).rename(filename) + Printer.print( + PrintChannel.PROGRESS_INFO, + f'\n### {prog_prefix}STARTING "{song_name}" ###\n' + ) - time_finished = time.time() + if ext == 'ogg': + global _ogg_delay_applied_once + if mode in ('album', 'playlist', 'extplaylist'): + if _ogg_delay_applied_once: + time.sleep(5) + else: + _ogg_delay_applied_once = True - Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{Path(filename).relative_to(Zotify.CONFIG.get_root_path())}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###' + "\n") + if track_id != scraped_song_id: + track_id = scraped_song_id - if Zotify.CONFIG.get_skip_previously_downloaded(): - add_to_archive(scraped_song_id, PurePath(filename).name, artists[0], name) - if not check_id: - add_to_directory_song_ids(filedir, scraped_song_id, PurePath(filename).name, artists[0], name) + track = TrackId.from_base62(track_id) + stream = Zotify.get_content_stream(track, Zotify.DOWNLOAD_QUALITY) + create_download_directory(filedir) + total_size = stream.input_stream.size + + prepare_download_loader.stop() + + time_start = time.time() + downloaded = 0 + with open(filename_temp, 'wb') as file, Printer.progress( + desc=song_name, + total=total_size, + unit='B', + unit_scale=True, + unit_divisor=1024, + disable=disable_progressbar + ) as p_bar: + b = 0 + while b < 5: + data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size()) + p_bar.update(file.write(data)) + downloaded += len(data) + b += 1 if data == b'' else 0 + if Zotify.CONFIG.get_download_real_time(): + 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() + + genres = get_song_genres(raw_artists, name) + + lyrics_lines: Optional[List[str]] = None + if Zotify.CONFIG.get_download_lyrics(): + try: + lyr_dir = Zotify.CONFIG.get_lyrics_location() or PurePath(filename).parent + lyr_name_tpl = Zotify.CONFIG.get_lyrics_filename() + lyr_name = lyr_name_tpl + lyr_name = lyr_name.replace('{artist}', fix_filename(artists[0])) + lyr_name = lyr_name.replace('{song_name}', fix_filename(name)) + lyr_name = lyr_name.replace('{album}', fix_filename(album_name)) + if Zotify.CONFIG.get_unique_lyrics_file(): + lrc_path = PurePath(lyr_dir).joinpath("lyrics.lrc") + else: + lrc_path = PurePath(lyr_dir).joinpath(f"{lyr_name}.lrc") + lyrics_lines = get_song_lyrics( + track_id, lrc_path, title=name, artists=artists, album=album_name, duration_ms=duration_ms, write_file=True + ) + except ValueError: + Printer.print(PrintChannel.SKIPS, f"### LYRICS_UNAVAILABLE: Lyrics for {song_name} not available or API returned empty/errored response ###") + + convert_audio_format(filename_temp) + try: + set_audio_tags( + filename_temp, + artists, + genres, + name, + album_name, + release_year, + disc_number, + track_number, + lyrics_lines + ) + set_music_thumbnail(filename_temp, image_url) + except Exception: + Printer.print(PrintChannel.ERRORS, "Unable to write metadata, ensure ffmpeg is installed and added to your PATH.") + + if filename_temp != filename: + Path(filename_temp).rename(filename) + + time_finished = time.time() + + Printer.print( + PrintChannel.DOWNLOADS, + f'### Downloaded "{song_name}" to "{Path(filename).relative_to(Zotify.CONFIG.get_root_path())}" ' + f'in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###\n' + ) + + if Zotify.CONFIG.get_skip_previously_downloaded(): + add_to_archive(scraped_song_id, PurePath(filename).name, artists[0], name) + if not check_id: + add_to_directory_song_ids(filedir, scraped_song_id, PurePath(filename).name, artists[0], name) + + if Zotify.CONFIG.get_bulk_wait_time(): + time.sleep(Zotify.CONFIG.get_bulk_wait_time()) + + return 'downloaded' - if Zotify.CONFIG.get_bulk_wait_time(): - time.sleep(Zotify.CONFIG.get_bulk_wait_time()) except Exception as e: + prepare_download_loader.stop() Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id)) for k in extra_keys: @@ -523,10 +530,12 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba Printer.print(PrintChannel.ERRORS, "\n") Printer.print(PrintChannel.ERRORS, str(e) + "\n") Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") - if Path(filename_temp).exists(): + if 'filename_temp' in locals() and Path(filename_temp).exists(): Path(filename_temp).unlink() + return 'errored' - prepare_download_loader.stop() + finally: + prepare_download_loader.stop() def convert_audio_format(filename) -> None: @@ -538,7 +547,7 @@ def convert_audio_format(filename) -> None: if file_codec != 'copy': bitrate = Zotify.CONFIG.get_transcode_bitrate() bitrates = { - #SpotiClub API permit the use of '320k' for free users, so we map 'auto' to that value. + #SpotiClub : API permit the use of '320k' for free users, so we map 'auto' to that value. 'auto': '320k', 'normal': '96k', 'high': '160k',