- 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:
@@ -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)
|
||||
|
Reference in New Issue
Block a user