- Changed API key validation from if not _key in self.constants.API_KEYS to if _key not in self.constants.API_KEYS for better readability.

Enhance RadioUtil playlist handling and deduplication

- Added checks to ensure playlists are initialized and not empty.
- Improved deduplication logic to prevent modifying the original playlist during iteration.
- Added logging for duplicate removal and playlist population.

Add cover art handling in rip_background.py

- Implemented functionality to attach album art if provided in metadata.
- Added error handling for cover art download failures.

Introduce unique filename handling in rip_background.py

- Added `ensure_unique_filename_in_dir` function to prevent overwriting files with the same name.

Refactor SRUtil for improved error handling and metadata fetching

- Introduced `MetadataFetchError` for better error management during metadata retrieval.
- Implemented `_safe_api_call` for resilient API calls with retry logic.
- Enhanced `get_artists_by_name` to optionally group results by artist name.
- Updated various methods to utilize the new error handling and retry mechanisms.
This commit is contained in:
2025-09-22 11:08:48 -04:00
parent e1194475b3
commit c2044711fb
9 changed files with 1466 additions and 354 deletions

View File

@@ -1,18 +1,9 @@
from typing import Optional, Any
from typing import Optional, Any, Callable
from uuid import uuid4
from urllib.parse import urlparse
import hashlib
import traceback
import logging
# Suppress all logging output from this module and its children
for name in [__name__, "utils.sr_wrapper"]:
logger = logging.getLogger(name)
logger.setLevel(logging.CRITICAL)
logger.propagate = False
for handler in logger.handlers:
handler.setLevel(logging.CRITICAL)
# Also set the root logger to CRITICAL as a last resort (may affect global logging)
logging.getLogger().setLevel(logging.CRITICAL)
import random
import asyncio
import os
@@ -24,6 +15,21 @@ from dotenv import load_dotenv
from rapidfuzz import fuzz
class MetadataFetchError(Exception):
"""Raised when metadata fetch permanently fails after retries."""
# Suppress all logging output from this module and its children
for name in [__name__, "utils.sr_wrapper"]:
logger = logging.getLogger(name)
logger.setLevel(logging.CRITICAL)
logger.propagate = False
for handler in logger.handlers:
handler.setLevel(logging.CRITICAL)
# Also set the root logger to CRITICAL as a last resort (may affect global logging)
logging.getLogger().setLevel(logging.CRITICAL)
load_dotenv()
@@ -65,6 +71,10 @@ class SRUtil:
self.MAX_METADATA_RETRIES = 5
self.METADATA_ALBUM_CACHE: dict[str, dict] = {}
self.RETRY_DELAY = 1.0 # seconds between retries
# Callback invoked when a 429 is first observed. Signature: (Exception) -> None or async
self.on_rate_limit: Optional[Callable[[Exception], Any]] = None
# Internal flag to avoid repeated notifications for the same runtime
self._rate_limit_notified = False
async def rate_limited_request(self, func, *args, **kwargs):
async with self.METADATA_SEMAPHORE:
@@ -73,9 +83,70 @@ class SRUtil:
if elapsed < self.METADATA_RATE_LIMIT:
await asyncio.sleep(self.METADATA_RATE_LIMIT - elapsed)
result = await func(*args, **kwargs)
self.last_request_time = time.time()
self.LAST_METADATA_REQUEST = time.time()
return result
async def _safe_api_call(self, func, *args, retries: int = 2, backoff: float = 0.5, **kwargs):
"""Call an async API function with resilient retry behavior.
- On AttributeError: attempt a `login()` once and retry.
- On connection-related errors (aiohttp.ClientError, OSError, Timeout):
attempt a `login()` and retry up to `retries` times.
- On 400/429 responses (message contains '400' or '429'): retry with backoff
without triggering login (to avoid excessive logins).
Returns the result or raises the last exception.
"""
last_exc: Optional[Exception] = None
for attempt in range(retries):
try:
return await func(*args, **kwargs)
except AttributeError as e:
# Probably missing/closed client internals: try re-login once
last_exc = e
try:
await self.streamrip_client.login()
except Exception:
pass
continue
except Exception as e:
last_exc = e
msg = str(e)
# Treat 400/429 as transient rate-limit/server responses — retry without login
if ("400" in msg or "429" in msg) and attempt < retries - 1:
# Notify on the first observed 429 (if a callback is set)
try:
if "429" in msg and not self._rate_limit_notified and self.on_rate_limit:
self._rate_limit_notified = True
try:
if asyncio.iscoroutinefunction(self.on_rate_limit):
asyncio.create_task(self.on_rate_limit(e))
else:
loop = asyncio.get_running_loop()
loop.run_in_executor(None, self.on_rate_limit, e)
except Exception:
pass
except Exception:
pass
await asyncio.sleep(backoff * (2 ** attempt))
continue
# Connection related errors — try to re-login then retry
if isinstance(e, (aiohttp.ClientError, OSError, ConnectionError, asyncio.TimeoutError)) or "Connection" in msg or "closed" in msg.lower():
try:
await self.streamrip_client.login()
except Exception:
pass
if attempt < retries - 1:
await asyncio.sleep(backoff * (2 ** attempt))
continue
# Unhandled / permanent error: re-raise after loop ends
# If we reach here, raise the last exception
if last_exc:
raise last_exc
return None
def is_fuzzy_match(self, expected, actual, threshold=80):
if not expected or not actual:
return False
@@ -95,6 +166,65 @@ class SRUtil:
deduped[norm] = entry
return list(deduped.values())
def group_artists_by_name(self, entries: list[dict], query: Optional[str] = None) -> list[dict]:
"""
Group artist entries by normalized display name and pick a primary candidate per name.
Returns a list of dicts where each dict contains the primary candidate plus
an `alternatives` list for other artists that share the same display name.
Scoring/selection policy:
- If `query` is provided, prefer an exact case-insensitive match.
- Otherwise prefer the entry with highest fuzzy match to `query`.
- Use `popularity` as a tiebreaker.
This keeps a single line in an autocomplete dropdown while preserving the
alternate choices (IDs) so the UI can show a submenu or a secondary picker.
"""
buckets: dict[str, list[dict]] = {}
for e in entries:
name = e.get("artist", "")
norm = name.strip().lower()
buckets.setdefault(norm, []).append(e)
out: list[dict] = []
for norm, items in buckets.items():
if len(items) == 1:
primary = items[0]
alternatives: list[dict] = []
else:
# Score each item
scored = []
for it in items:
score = 0.0
if query:
try:
if it.get("artist", "").strip().lower() == query.strip().lower():
score += 1000.0
else:
score += float(fuzz.token_set_ratio(query, it.get("artist", "")))
except Exception:
score += 0.0
# add small weight for popularity if present
pop = it.get("popularity") or 0
try:
score += float(pop) / 100.0
except Exception:
pass
scored.append((score, it))
scored.sort(key=lambda x: x[0], reverse=True)
primary = scored[0][1]
alternatives = [it for _, it in scored[1:]]
out.append({
"artist": primary.get("artist"),
"id": primary.get("id"),
"popularity": primary.get("popularity"),
"alternatives": alternatives,
})
return out
def format_duration(self, seconds):
if not seconds:
return None
@@ -179,22 +309,23 @@ class SRUtil:
for t in album_json.get("tracks", [])
]
async def get_artists_by_name(self, artist_name: str) -> Optional[list]:
"""Get artist(s) by name. Retry login only on authentication failure. Rate limit and retry on 400/429."""
import asyncio
async def get_artists_by_name(self, artist_name: str, group: bool = False) -> Optional[list]:
"""Get artist(s) by name.
Args:
artist_name: query string to search for.
group: if True return grouped results (one primary per display name with
`alternatives` list). If False return raw search items (legacy shape).
Retry login only on authentication failure. Rate limit and retry on 400/429.
"""
artists_out: list[dict] = []
max_retries = 4
delay = 1.0
for attempt in range(max_retries):
try:
artists = await self.streamrip_client.search(
media_type="artist", query=artist_name
)
artists = await self._safe_api_call(self.streamrip_client.search, media_type="artist", query=artist_name, retries=3)
break
except AttributeError:
await self.streamrip_client.login()
if attempt == max_retries - 1:
return None
except Exception as e:
msg = str(e)
if ("400" in msg or "429" in msg) and attempt < max_retries - 1:
@@ -205,18 +336,30 @@ class SRUtil:
return None
else:
return None
artists = artists[0].get("items", [])
# `artists` can be None or a list of result pages — guard accordingly
if not artists:
return None
# If the client returned paged results (list), pick first page dict
if isinstance(artists, list):
artists_page = artists[0] if len(artists) > 0 else {}
else:
artists_page = artists
artists_items = artists_page.get("items", []) if isinstance(artists_page, dict) else []
if not artists_items:
return None
artists_out = [
{
"artist": res["name"],
"id": res["id"],
"popularity": res.get("popularity", 0),
}
for res in artists
for res in artists_items
if "name" in res and "id" in res
]
artists_out = self.dedupe_by_key("artist", artists_out) # Remove duplicates
if group:
return self.group_artists_by_name(artists_out, query=artist_name)
return artists_out
async def get_albums_by_artist_id(self, artist_id: int) -> Optional[list | dict]:
@@ -228,14 +371,8 @@ class SRUtil:
delay = 1.0
for attempt in range(max_retries):
try:
metadata = await self.streamrip_client.get_metadata(
item_id=artist_id_str, media_type="artist"
)
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, artist_id_str, "artist", retries=3)
break
except AttributeError:
await self.streamrip_client.login()
if attempt == max_retries - 1:
return None
except Exception as e:
msg = str(e)
if ("400" in msg or "429" in msg) and attempt < max_retries - 1:
@@ -300,12 +437,9 @@ class SRUtil:
album_id_str: str = str(album_id)
for attempt in range(2):
try:
metadata = await self.streamrip_client.get_metadata(
item_id=album_id_str, media_type="album"
)
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, item_id=album_id_str, media_type="album", retries=2)
break
except AttributeError:
await self.streamrip_client.login()
except Exception:
if attempt == 1:
return None
else:
@@ -329,10 +463,11 @@ class SRUtil:
Optional[list[dict]]: List of tracks or None if not found.
"""
album_id_str = str(album_id)
await self.streamrip_client.login()
metadata = await self.streamrip_client.get_metadata(
item_id=album_id_str, media_type="album"
)
try:
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, item_id=album_id_str, media_type="album", retries=2)
except Exception as e:
logging.warning("get_tracks_by_album_id failed: %s", e)
return None
if not metadata:
logging.warning("No metadata found for album ID: %s", album_id)
return None
@@ -360,21 +495,16 @@ class SRUtil:
Optional[dict]: The track details or None if not found.
TODO: Reimplement using StreamRip
"""
if not self.streamrip_client.logged_in:
await self.streamrip_client.login()
try:
search_res = await self.streamrip_client.search(media_type="track",
query=f"{artist} - {song}",
)
try:
search_res = await self._safe_api_call(self.streamrip_client.search, media_type="track", query=f"{artist} - {song}", retries=3)
logging.critical("Result: %s", search_res)
return search_res[0].get('items')
return search_res[0].get('items') if search_res and isinstance(search_res, list) else []
except Exception as e:
traceback.print_exc()
logging.critical("Search Exception: %s", str(e))
if n < 3:
n+=1
n += 1
return await self.get_tracks_by_artist_song(artist, song, n)
finally:
return []
# return []
@@ -399,18 +529,13 @@ class SRUtil:
quality_int = 1
track_id_str: str = str(track_id)
await self.streamrip_client.login()
# Ensure client is logged in via safe call when needed inside _safe_api_call
try:
logging.critical("Using quality_int: %s", quality_int)
track = await self.streamrip_client.get_downloadable(
track_id=track_id_str, quality=quality_int
)
except AttributeError:
await self.streamrip_client.login()
track = await self.streamrip_client.get_downloadable(
track_id=track_id_str, quality=quality_int
)
track = await self._safe_api_call(self.streamrip_client.get_downloadable, track_id=track_id_str, quality=quality_int, retries=3)
except Exception as e:
logging.warning("get_stream_url_by_track_id failed: %s", e)
return None
if not track:
logging.warning("No track found for ID: %s", track_id)
return None
@@ -427,8 +552,7 @@ class SRUtil:
"""
for attempt in range(1, self.MAX_METADATA_RETRIES + 1):
try:
await self.streamrip_client.login()
await self._safe_api_call(self.streamrip_client.login, retries=1)
# Track metadata
metadata = await self.rate_limited_request(
self.streamrip_client.get_metadata, str(track_id), "track"
@@ -443,7 +567,7 @@ class SRUtil:
album_metadata = self.METADATA_ALBUM_CACHE[album_id]
else:
album_metadata = await self.rate_limited_request(
self.streamrip_client.get_metadata, album_id, "album"
lambda i, t: self._safe_api_call(self.streamrip_client.get_metadata, i, t, retries=2), album_id, "album"
)
if not album_metadata:
return None
@@ -456,6 +580,9 @@ class SRUtil:
album_metadata, metadata
)
# Include album id so callers can fetch cover art if desired
combined_metadata["album_id"] = album_id
logging.info(
"Combined metadata for track ID %s (attempt %d): %s",
track_id,
@@ -483,7 +610,10 @@ class SRUtil:
track_id,
self.MAX_METADATA_RETRIES,
)
return None
# Raise a specific exception so callers can react (e.g. notify)
raise MetadataFetchError(f"Metadata fetch failed permanently for track {track_id} after {self.MAX_METADATA_RETRIES} attempts: {e}")
# If we reach here without returning, raise a generic metadata error
raise MetadataFetchError(f"Metadata fetch failed for track {track_id}")
async def download(self, track_id: int, quality: str = "LOSSLESS") -> bool | str:
@@ -495,7 +625,7 @@ class SRUtil:
bool
"""
try:
await self.streamrip_client.login()
await self._safe_api_call(self.streamrip_client.login, retries=1)
track_url = await self.get_stream_url_by_track_id(track_id)
if not track_url:
return False
@@ -507,6 +637,12 @@ class SRUtil:
f"{self.streamrip_config.session.downloads.folder}/{unique}"
)
dl_path = f"{dl_folder_path}/{track_id}.{parsed_url_ext}"
# ensure download folder exists
try:
os.makedirs(dl_folder_path, exist_ok=True)
except Exception:
pass
async with aiohttp.ClientSession() as session:
async with session.get(
track_url, headers={}, timeout=aiohttp.ClientTimeout(total=60)