From dc20e4367cae14af2707876f6018bce523139448 Mon Sep 17 00:00:00 2001 From: logykk Date: Sat, 18 Dec 2021 22:11:43 +1300 Subject: [PATCH] realtime downloading for podcasts --- zspotify/__main__.py | 20 ++++++++++++++++ zspotify/app.py | 3 ++- zspotify/config.py | 8 ++++++- zspotify/podcast.py | 56 ++++++++++++++++++++++++++++++-------------- zspotify/track.py | 4 ++-- zspotify/utils.py | 2 ++ zspotify/zspotify.py | 18 ++++---------- 7 files changed, 76 insertions(+), 35 deletions(-) diff --git a/zspotify/__main__.py b/zspotify/__main__.py index b06ade3c..41a75eea 100644 --- a/zspotify/__main__.py +++ b/zspotify/__main__.py @@ -1,3 +1,23 @@ +#! /usr/bin/env python3 + +""" +ZSpotify +It's like youtube-dl, but for Spotify. +Copyright (C) 2021 Deathmonger/Footsiefat and ZSpotify Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + import argparse from app import client diff --git a/zspotify/app.py b/zspotify/app.py index c30bceb8..772d9a33 100644 --- a/zspotify/app.py +++ b/zspotify/app.py @@ -19,7 +19,8 @@ def client(args) -> None: """ Connects to spotify to perform query's and get songs to download """ ZSpotify(args) - Printer.print(PrintChannel.SPLASH, splash()) + if ZSpotify.CONFIG.get_print_splash(): + Printer.print(PrintChannel.SPLASH, splash()) if ZSpotify.check_premium(): Printer.print(PrintChannel.SPLASH, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n') diff --git a/zspotify/config.py b/zspotify/config.py index 9aee4225..00628ae6 100644 --- a/zspotify/config.py +++ b/zspotify/config.py @@ -31,12 +31,14 @@ MD_ALLGENRES = 'MD_ALLGENRES' MD_GENREDELIMITER = 'MD_GENREDELIMITER' PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO' PRINT_WARNINGS = 'PRINT_WARNINGS' +RETRY_ATTEMPTS = 'RETRY_ATTEMPTS' CONFIG_VALUES = { ROOT_PATH: { 'default': '../ZSpotify Music/', 'type': str, 'arg': '--root-path' }, ROOT_PODCAST_PATH: { 'default': '../ZSpotify Podcasts/', 'type': str, 'arg': '--root-podcast-path' }, SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' }, SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, + RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' }, DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' }, ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' }, @@ -49,7 +51,7 @@ CONFIG_VALUES = { SONG_ARCHIVE: { 'default': '.song_archive', 'type': str, 'arg': '--song-archive' }, CREDENTIALS_LOCATION: { 'default': 'credentials.json', 'type': str, 'arg': '--credentials-location' }, OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, - PRINT_SPLASH: { 'default': 'True', 'type': bool, 'arg': '--print-splash' }, + PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' }, PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, @@ -207,6 +209,10 @@ class Config: def get_allGenres(cls) -> bool: return cls.get(MD_ALLGENRES) + @classmethod + def get_print_splash(cls) -> bool: + return cls.get(PRINT_SPLASH) + @classmethod def get_allGenresDelimiter(cls) -> bool: return cls.get(MD_GENREDELIMITER) diff --git a/zspotify/podcast.py b/zspotify/podcast.py index 93de8b6f..7b692f39 100644 --- a/zspotify/podcast.py +++ b/zspotify/podcast.py @@ -1,22 +1,29 @@ import os +import time from typing import Optional, Tuple from librespot.metadata import EpisodeId -from const import (ERROR, ID, ITEMS, NAME, SHOW) +from const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS from termoutput import PrintChannel, Printer from utils import create_download_directory, fix_filename from zspotify import ZSpotify +from loader import Loader + EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes' SHOWS_URL = 'https://api.spotify.com/v1/shows' def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]: - (raw, info) = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}') + with Loader(PrintChannel.PROGRESS_INFO, "Fetching episode information..."): + (raw, info) = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}') + if not info: + Printer.print(PrintChannel.ERRORS, "### INVALID EPISODE ID ###") + duration_ms = info[DURATION_MS] if ERROR in info: return None, None - return fix_filename(info[SHOW][NAME]), fix_filename(info[NAME]) + return fix_filename(info[SHOW][NAME]), duration_ms, fix_filename(info[NAME]) def get_show_episodes(show_id_str) -> list: @@ -24,14 +31,15 @@ def get_show_episodes(show_id_str) -> list: offset = 0 limit = 50 - while True: - resp = ZSpotify.invoke_url_with_params( - f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset) - offset += limit - for episode in resp[ITEMS]: - episodes.append(episode[ID]) - if len(resp[ITEMS]) < limit: - break + with Loader(PrintChannel.PROGRESS_INFO, "Fetching episodes..."): + while True: + resp = ZSpotify.invoke_url_with_params( + f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset) + offset += limit + for episode in resp[ITEMS]: + episodes.append(episode[ID]) + if len(resp[ITEMS]) < limit: + break return episodes @@ -64,12 +72,14 @@ def download_podcast_directly(url, filename): def download_episode(episode_id) -> None: - podcast_name, episode_name = get_episode_info(episode_id) - + podcast_name, duration_ms, episode_name = get_episode_info(episode_id) extra_paths = podcast_name + '/' + prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...") + prepare_download_loader.start() if podcast_name is None: Printer.print(PrintChannel.SKIPS, '### SKIPPING: (EPISODE NOT FOUND) ###') + prepare_download_loader.stop() else: filename = podcast_name + ' - ' + episode_name @@ -94,21 +104,31 @@ def download_episode(episode_id) -> None: and ZSpotify.CONFIG.get_skip_existing_files() ): Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###") + prepare_download_loader.stop() return + prepare_download_loader.stop() + time_start = time.time() + downloaded = 0 with open(filepath, 'wb') as file, Printer.progress( desc=filename, total=total_size, unit='B', unit_scale=True, unit_divisor=1024 - ) as bar: + ) as p_bar: + prepare_download_loader.stop() for _ in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1): - bar.update(file.write( - stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size()))) + 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(): + 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) else: filepath = os.path.join(download_directory, f"{filename}.mp3") download_podcast_directly(direct_download_url, filepath) - # convert_audio_format(ROOT_PODCAST_PATH + - # extra_paths + filename + '.ogg') + prepare_download_loader.stop() diff --git a/zspotify/track.py b/zspotify/track.py index c6cf7aad..b925bc8e 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -15,9 +15,9 @@ from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_down get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds from zspotify import ZSpotify import traceback - from loader import Loader + def get_saved_tracks() -> list: """ Returns user's saved tracks """ songs = [] @@ -189,7 +189,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba unit_divisor=1024, disable=disable_progressbar ) as p_bar: - for chunk in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1): + for _ 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) diff --git a/zspotify/utils.py b/zspotify/utils.py index f56ce5d0..ef9a7b88 100644 --- a/zspotify/utils.py +++ b/zspotify/utils.py @@ -280,3 +280,5 @@ def fmt_seconds(secs: float) -> str: 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 977c4b0a..37afdabf 100644 --- a/zspotify/zspotify.py +++ b/zspotify/zspotify.py @@ -1,11 +1,3 @@ -#! /usr/bin/env python3 - -""" -ZSpotify -It's like youtube-dl, but for Spotify. - -(Made by Deathmonger/Footsiefat - @doomslayer117:matrix.org) -""" import os import os.path from getpass import getpass @@ -81,17 +73,17 @@ class ZSpotify: return requests.get(url, headers=headers, params=params).json() @classmethod - def invoke_url(cls, url, tryCount = 0): + def invoke_url(cls, url, tryCount=0): # we need to import that here, otherwise we will get circular imports! - from termoutput import Printer, PrintChannel + from termoutput import Printer, PrintChannel headers = cls.get_auth_header() response = requests.get(url, headers=headers) responsetext = response.text responsejson = response.json() - + if 'error' in responsejson: - if tryCount < 5: - Printer.print(PrintChannel.WARNINGS, f"Spotify API Error (try {tryCount}) ({responsejson['error']['status']}): {responsejson['error']['message']}") + if tryCount < (cls.CONFIG.retry_attemps - 1): + Printer.print(PrintChannel.WARNINGS, f"Spotify API Error (try {tryCount + 1}) ({responsejson['error']['status']}): {responsejson['error']['message']}") time.sleep(5) return cls.invoke_url(url, tryCount + 1)