diff --git a/zotify/__main__.py b/zotify/__main__.py index 7c4166e..78d4eaf 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -6,10 +6,12 @@ """ import argparse +import sys from zotify.app import client from zotify.config import Zotify, CONFIG_VALUES, DEPRECIATED_CONFIGS -from zotify.termoutput import Printer +from zotify.termoutput import Printer, PrintChannel +from zotify.exceptions import LoginError, RateLimitError, ConnectionDropError class DepreciatedAction(argparse.Action): @@ -134,8 +136,20 @@ def main(): ) args = parser.parse_args() - Zotify.boot(args) - client(args, modes) + try: + Zotify.boot(args) + client(args, modes) + except LoginError: + sys.exit(133) + except RateLimitError: + sys.exit(102) + except ConnectionDropError: + sys.exit(101) + + if Zotify.DOWNLOAD_ERRORS: + for err in Zotify.DOWNLOAD_ERRORS: + Printer.hashtaged(PrintChannel.ERROR, err) + sys.exit(100) if __name__ == '__main__': diff --git a/zotify/api.py b/zotify/api.py index 88ee000..c17e74d 100644 --- a/zotify/api.py +++ b/zotify/api.py @@ -945,6 +945,7 @@ def download(self, parent_stack: ParentStack) -> None: if stream is None: Printer.hashtaged(PrintChannel.ERROR, 'SKIPPING TRACK - FAILED TO GET CONTENT STREAM\n' + f'Track_ID: {self.id}') + Zotify.DOWNLOAD_ERRORS.append(f"Track {self.id}: Failed to get content stream") return self.set_dl_status("Downloading Stream") @@ -970,6 +971,7 @@ def download(self, parent_stack: ParentStack) -> None: except Exception as e: Printer.hashtaged(PrintChannel.ERROR, 'FAILED TO WRITE METADATA\n') Printer.traceback(e) + Zotify.DOWNLOAD_ERRORS.append(f"Track {self.id}: Failed to write metadata ({e})") Interface.dl_complete(self, path, time_elapsed_dl, time_elapsed_ffmpeg) @@ -1167,6 +1169,7 @@ def download(self, parent_stack: ParentStack): if stream is None: Printer.hashtaged(PrintChannel.ERROR, 'SKIPPING EPISODE - FAILED TO GET CONTENT STREAM\n' + f'Episode_ID: {self.id}') + Zotify.DOWNLOAD_ERRORS.append(f"Episode {self.id}: Failed to get content stream") return time_elapsed_dl = self.fetch_content_stream(stream, temppath, parent_stack) else: @@ -1175,6 +1178,7 @@ def download(self, parent_stack: ParentStack): except Exception as e: Printer.hashtaged(PrintChannel.ERROR, 'FAILED TO DOWNLOAD EPISODE DIRECTLY') Printer.traceback(e) + Zotify.DOWNLOAD_ERRORS.append(f"Episode {self.id}: Failed to download directly ({e})") return try: diff --git a/zotify/config.py b/zotify/config.py index c7c35e2..9e33e0c 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -1,6 +1,7 @@ import json import logging import sys +import queue import re import requests from binascii import hexlify @@ -21,6 +22,7 @@ from zotify.const import * from zotify.termoutput import Printer, PrintChannel, Loader +from zotify.exceptions import LoginError, RateLimitError, ConnectionDropError Streamer = CdnManager.Streamer @@ -653,6 +655,7 @@ class Zotify: # DYNAMIC PER QUERY TOTAL_API_CALLS : int = None DATETIME_LAUNCH : str = None + DOWNLOAD_ERRORS : list[str] = [] @classmethod def start(cls) -> None: @@ -660,6 +663,7 @@ def start(cls) -> None: Printer.debug(f"Total API Calls: {cls.TOTAL_API_CALLS}") cls.DATETIME_LAUNCH = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") cls.TOTAL_API_CALLS = 0 + cls.DOWNLOAD_ERRORS = [] @classmethod def login(cls, args): @@ -766,11 +770,17 @@ def boot(cls, args): login_try = 0 while login_try <= cls.CONFIG.get_retry_attempts(): login_try += 1 - try: cls.login(args) + try: + cls.login(args) + break except ConnectionError as e: + pause = 3 ** login_try Printer.hashtaged(PrintChannel.WARNING, f'LOGIN FAILED ({e.args[0]})\n' + - 'TRYING AGAIN AFTER SMALL WAIT') - sleep(3) + f'TRYING AGAIN AFTER {pause}s WAIT') + sleep(pause) + else: + Printer.hashtaged(PrintChannel.ERROR, 'MAX LOGIN RETRIES REACHED. EXITING.') + raise LoginError("Max login retries reached") cls.LOGGER = logging.getLogger("zotify.debug") prem, quality, bitrate = cls.parse_dl_quality(cls.CONFIG.get_download_qual_pref()) @@ -861,7 +871,7 @@ def choose_token() -> str: tryCount += 1 if tryCount > cls.CONFIG.get_retry_attempts(): break - sleep(retry_delay if not expectFail else 1) + sleep((retry_delay * tryCount) if not expectFail else 1) if not expectFail: Printer.hashtaged(PrintChannel.API_ERROR, f'RETRY LIMIT EXCEDED\n' + @@ -950,12 +960,26 @@ def get_content_stream(cls, content, use_qual_pref: bool = True) -> Streamer | N 'MAY BE CAUSED BY RATE LIMITS - CONSIDER INCREASING `BULK_WAIT_TIME`\n' + f'GID: {gid[5:]} - File_ID: {fileid[8:]}') Printer.logger("\n".join(e.args), PrintChannel.ERROR) + raise RateLimitError("Rate limited when fetching audio key") except ConnectionError as e: if "Status code " not in e.args[0]: raise status_code = e.args[0].split("Status code ")[1] Printer.hashtaged(PrintChannel.ERROR, 'FAILED TO FETCH AUDIO FILE\n' + f'CONNECTION ERROR WHEN FETCHING CONTENT STREAM - STATUS CODE {status_code}') Printer.logger("\n".join(e.args), PrintChannel.ERROR) + if status_code.strip() == "429": + raise RateLimitError("Rate limited (status code 429)") + raise ConnectionDropError(f"Connection error: status code {status_code}") + except OSError as e: + Printer.hashtaged(PrintChannel.ERROR, 'FAILED TO FETCH AUDIO STREAM\n' + + 'NETWORK CONNECTION LOST - SPOTIFY TERMINATED SESSION') + Printer.traceback(e) + raise ConnectionDropError("Spotify terminated session") + except queue.Empty as e: + Printer.hashtaged(PrintChannel.ERROR, 'FAILED TO FETCH AUDIO STREAM\n' + + 'NETWORK CONNECTION LOST - QUEUE TIMEOUT WAITING FOR RESPONSE') + Printer.traceback(e) + raise ConnectionDropError("Spotify terminated session (Queue Empty Timeout)") except Exception as e: if risky_method: cls.FORCE_STREAM_API_CALLS = True diff --git a/zotify/exceptions.py b/zotify/exceptions.py new file mode 100644 index 0000000..3e451b2 --- /dev/null +++ b/zotify/exceptions.py @@ -0,0 +1,15 @@ +class ZotifyException(Exception): + """Base exception for Zotify""" + pass + +class LoginError(ZotifyException): + """Raised when Zotify fails to log in""" + pass + +class RateLimitError(ZotifyException): + """Raised when Spotify rate limits the session""" + pass + +class ConnectionDropError(ZotifyException): + """Raised when the connection to Spotify drops unexpectedly""" + pass