diff --git a/streamrip/client/client.py b/streamrip/client/client.py index 53eb08a4..63b1d097 100644 --- a/streamrip/client/client.py +++ b/streamrip/client/client.py @@ -23,6 +23,29 @@ class Client(ABC): session: aiohttp.ClientSession logged_in: bool + def __init__(self): + self._quality_warned = False + + def clamp_quality(self, requested_quality: int) -> int: + """Clamp requested quality to maximum supported by this source. + + Warns once if quality exceeds maximum. + Returns the clamped quality value. + """ + if requested_quality > self.max_quality: + if not self._quality_warned: + logger.warning( + "Requested quality %d exceeds %s maximum (quality %d). " + "Using highest available quality (%d) for all tracks.", + requested_quality, + self.source.capitalize(), + self.max_quality, + self.max_quality, + ) + self._quality_warned = True + return self.max_quality + return requested_quality + @abstractmethod async def login(self): raise NotImplementedError diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 056463f9..c2e9bf6f 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -7,6 +7,12 @@ from Cryptodome.Cipher import AES from ..config import Config + +# Suppress urllib3 connection pool warnings from deezer-py library +# These are performance warnings, not failures - downloads still work +logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR) + + from ..exceptions import ( AuthenticationError, MissingCredentialsError, @@ -35,6 +41,7 @@ class DeezerClient(Client): max_quality = 2 def __init__(self, config: Config): + super().__init__() self.global_config = config self.client = deezer.Deezer() self.logged_in = False @@ -79,7 +86,8 @@ async def get_track(self, item_id: str) -> dict: asyncio.to_thread(self.client.api.get_album_tracks, album_id), ) except Exception as e: - logger.error(f"Error fetching album of track {item_id}: {e}") + # Album metadata unavailable (likely geo-restricted) - using track metadata only + logger.debug(f"Album {album_id} unavailable for track {item_id}: {e}") return item album_metadata["tracks"] = album_tracks["data"] @@ -152,6 +160,9 @@ async def get_downloadable( fallback_id = track_info.get("FALLBACK", {}).get("SNG_ID") + # Clamp quality to maximum supported by Deezer + quality = self.clamp_quality(quality) + quality_map = [ (9, "MP3_128"), # quality 0 (3, "MP3_320"), # quality 1 @@ -161,18 +172,21 @@ async def get_downloadable( int(track_info.get(f"FILESIZE_{format}", 0)) for _, format in quality_map ] dl_info["quality_to_size"] = size_map - + # Check if requested quality is available if size_map[quality] == 0: if self.config.lower_quality_if_not_available: # Fallback to lower quality + original_quality = quality while size_map[quality] == 0 and quality > 0: - logger.warning( - "The requested quality %s is not available. Falling back to quality %s", + quality -= 1 + # Only log if fallback occurred + if quality != original_quality: + logger.debug( + "Quality %s unavailable, using quality %s instead", + original_quality, quality, - quality - 1, ) - quality -= 1 else: # No fallback - raise error raise NonStreamableError( diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 3b29b504..61f22460 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -129,7 +129,8 @@ def __init__(self, session: aiohttp.ClientSession, info: dict): ] if len(qualities_available) == 0: raise NonStreamableError( - "Missing download info. Skipping.", + "Track not available for download (no file sizes returned by API). " + "Likely removed, geo-restricted, or licensing issue.", ) max_quality_available = max(qualities_available) self.quality = min(info["quality"], max_quality_available) diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 734e2b82..40bf1d68 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -146,6 +146,7 @@ class QobuzClient(Client): max_quality = 4 def __init__(self, config: Config): + super().__init__() self.logged_in = False self.config = config self.rate_limiter = self.get_rate_limiter( diff --git a/streamrip/client/soundcloud.py b/streamrip/client/soundcloud.py index f10ec7cf..82945b60 100644 --- a/streamrip/client/soundcloud.py +++ b/streamrip/client/soundcloud.py @@ -22,6 +22,7 @@ class SoundcloudClient(Client): source = "soundcloud" + max_quality = 3 # SoundCloud provides whatever quality is available logged_in = False NON_STREAMABLE = "_non_streamable" @@ -29,6 +30,7 @@ class SoundcloudClient(Client): NOT_RESOLVED = "_not_resolved" def __init__(self, config: Config): + super().__init__() self.global_config = config self.config = config.session.soundcloud self.rate_limiter = self.get_rate_limiter( diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 1cc189ae..53a9caa6 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -42,6 +42,7 @@ class TidalClient(Client): max_quality = 3 def __init__(self, config: Config): + super().__init__() self.logged_in = False self.global_config = config self.config = config.session.tidal diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 383f2465..f02bb4e7 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -49,20 +49,37 @@ async def resolve(self) -> Track | None: try: resp = await self.client.get_metadata(self.id, "track") except NonStreamableError as e: - logger.error(f"Could not stream track {self.id}: {e}") + # Try to get track name from error context + track_name = f"track {self.id}" + logger.error(f"Could not stream {track_name}: {e}") return None + # Extract track info for better error messages + try: + track_title = resp.get("title", "Unknown Title") + # Handle different API response formats for artist + artist_info = resp.get("artist") or resp.get("artists", []) + if isinstance(artist_info, dict): + track_artist = artist_info.get("name", "Unknown Artist") + elif isinstance(artist_info, list) and len(artist_info) > 0: + track_artist = artist_info[0].get("name", "Unknown Artist") + else: + track_artist = "Unknown Artist" + track_name = f'"{track_title}" by {track_artist}' + except Exception: + track_name = f"track {self.id}" + album = AlbumMetadata.from_track_resp(resp, self.client.source) if album is None: logger.error( - f"Track ({self.id}) not available for stream on {self.client.source}", + f"{track_name} not available for stream on {self.client.source}", ) self.db.set_failed(self.client.source, "track", self.id) return None meta = TrackMetadata.from_resp(album, self.client.source, resp) if meta is None: logger.error( - f"Track ({self.id}) not available for stream on {self.client.source}", + f"{track_name} not available for stream on {self.client.source}", ) self.db.set_failed(self.client.source, "track", self.id) return None @@ -80,7 +97,7 @@ async def resolve(self) -> Track | None: self.client.get_downloadable(self.id, quality), ) except NonStreamableError as e: - logger.error(f"Error fetching download info for track {self.id}: {e}") + logger.error(f"Error fetching download info for {track_name}: {e}") self.db.set_failed(self.client.source, "track", self.id) return None @@ -127,7 +144,8 @@ async def _resolve_download(item: PendingPlaylistTrack): return await track.rip() except Exception as e: - logger.error(f"Error downloading track: {e}") + # Unexpected error - specific errors are already logged in resolve() + logger.error(f"Unexpected error processing track {item.id}: {e}", exc_info=True) batches = self.batch( [_resolve_download(track) for track in self.tracks], diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index aca2500e..ff52bda0 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -162,7 +162,11 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: def from_deezer(cls, resp: dict) -> AlbumMetadata | None: album = resp.get("title", "Unknown Album") tracktotal = typed(resp.get("track_total", 0) or resp.get("nb_tracks", 0), int) - disctotal = typed(resp["tracks"][-1]["disk_number"], int) + # Handle empty tracks list - use 1 as default for disctotal + if resp["tracks"]: + disctotal = typed(resp["tracks"][-1]["disk_number"], int) + else: + disctotal = 1 genres = [typed(g["name"], str) for g in resp["genres"]["data"]] date = typed(resp["release_date"], str) diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 2b3f532e..78087dac 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -194,7 +194,15 @@ async def url(ctx, urls): if version_coro is not None: latest_version, notes = await version_coro - if latest_version != __version__: + # Only show update message if latest version is actually newer than running version + def version_tuple(v): + """Convert version string to tuple for comparison.""" + try: + return tuple(map(int, v.split('.'))) + except (ValueError, AttributeError): + return (0, 0, 0) + + if version_tuple(latest_version) > version_tuple(__version__): console.print( f"\n[green]A new version of streamrip [cyan]v{latest_version}[/cyan]" " is available! Run [white][bold]pip3 install streamrip --upgrade[/bold][/white]"