Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions streamrip/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions streamrip/client/deezer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion streamrip/client/downloadable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions streamrip/client/qobuz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions streamrip/client/soundcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@

class SoundcloudClient(Client):
source = "soundcloud"
max_quality = 3 # SoundCloud provides whatever quality is available
logged_in = False

NON_STREAMABLE = "_non_streamable"
ORIGINAL_DOWNLOAD = "_original_download"
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(
Expand Down
1 change: 1 addition & 0 deletions streamrip/client/tidal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions streamrip/media/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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],
Expand Down
6 changes: 5 additions & 1 deletion streamrip/metadata/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion streamrip/rip/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down