docstrings / formatting

This commit is contained in:
2025-09-23 13:17:34 -04:00
parent c2044711fb
commit 19afb287cd
16 changed files with 1165 additions and 428 deletions

View File

@@ -30,7 +30,6 @@ for name in [__name__, "utils.sr_wrapper"]:
logging.getLogger().setLevel(logging.CRITICAL)
load_dotenv()
@@ -66,7 +65,9 @@ class SRUtil:
self.streamrip_client = TidalClient(self.streamrip_config)
self.MAX_CONCURRENT_METADATA_REQUESTS = 2
self.METADATA_RATE_LIMIT = 1.25
self.METADATA_SEMAPHORE = asyncio.Semaphore(self.MAX_CONCURRENT_METADATA_REQUESTS)
self.METADATA_SEMAPHORE = asyncio.Semaphore(
self.MAX_CONCURRENT_METADATA_REQUESTS
)
self.LAST_METADATA_REQUEST = 0
self.MAX_METADATA_RETRIES = 5
self.METADATA_ALBUM_CACHE: dict[str, dict] = {}
@@ -77,16 +78,18 @@ class SRUtil:
self._rate_limit_notified = False
async def rate_limited_request(self, func, *args, **kwargs):
async with self.METADATA_SEMAPHORE:
now = time.time()
elapsed = now - self.LAST_METADATA_REQUEST
if elapsed < self.METADATA_RATE_LIMIT:
await asyncio.sleep(self.METADATA_RATE_LIMIT - elapsed)
result = await func(*args, **kwargs)
self.LAST_METADATA_REQUEST = time.time()
return result
async with self.METADATA_SEMAPHORE:
now = time.time()
elapsed = now - self.LAST_METADATA_REQUEST
if elapsed < self.METADATA_RATE_LIMIT:
await asyncio.sleep(self.METADATA_RATE_LIMIT - elapsed)
result = await func(*args, **kwargs)
self.LAST_METADATA_REQUEST = time.time()
return result
async def _safe_api_call(self, func, *args, retries: int = 2, backoff: float = 0.5, **kwargs):
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.
@@ -116,7 +119,11 @@ class SRUtil:
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:
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):
@@ -128,17 +135,29 @@ class SRUtil:
pass
except Exception:
pass
await asyncio.sleep(backoff * (2 ** attempt))
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():
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))
await asyncio.sleep(backoff * (2**attempt))
continue
# Unhandled / permanent error: re-raise after loop ends
@@ -151,10 +170,23 @@ class SRUtil:
if not expected or not actual:
return False
return fuzz.token_set_ratio(expected.lower(), actual.lower()) >= threshold
def is_metadata_match(self, expected_artist, expected_album, expected_title, found_artist, found_album, found_title, threshold=80):
def is_metadata_match(
self,
expected_artist,
expected_album,
expected_title,
found_artist,
found_album,
found_title,
threshold=80,
):
artist_match = self.is_fuzzy_match(expected_artist, found_artist, threshold)
album_match = self.is_fuzzy_match(expected_album, found_album, threshold) if expected_album else True
album_match = (
self.is_fuzzy_match(expected_album, found_album, threshold)
if expected_album
else True
)
title_match = self.is_fuzzy_match(expected_title, found_title, threshold)
return artist_match and album_match and title_match
@@ -166,7 +198,9 @@ class SRUtil:
deduped[norm] = entry
return list(deduped.values())
def group_artists_by_name(self, entries: list[dict], query: Optional[str] = None) -> list[dict]:
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.
@@ -199,10 +233,15 @@ class SRUtil:
score = 0.0
if query:
try:
if it.get("artist", "").strip().lower() == query.strip().lower():
if (
it.get("artist", "").strip().lower()
== query.strip().lower()
):
score += 1000.0
else:
score += float(fuzz.token_set_ratio(query, it.get("artist", "")))
score += float(
fuzz.token_set_ratio(query, it.get("artist", ""))
)
except Exception:
score += 0.0
# add small weight for popularity if present
@@ -216,12 +255,14 @@ class SRUtil:
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,
})
out.append(
{
"artist": primary.get("artist"),
"id": primary.get("id"),
"popularity": primary.get("popularity"),
"alternatives": alternatives,
}
)
return out
@@ -230,14 +271,16 @@ class SRUtil:
return None
m, s = divmod(seconds, 60)
return f"{m}:{s:02}"
def _get_tidal_cover_url(self, uuid, size):
"""Generate a tidal cover url.
:param uuid: VALID uuid string
:param size:
"""
TIDAL_COVER_URL = "https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
TIDAL_COVER_URL = (
"https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
)
possibles = (80, 160, 320, 640, 1280)
assert size in possibles, f"size must be in {possibles}"
return TIDAL_COVER_URL.format(
@@ -246,8 +289,6 @@ class SRUtil:
width=size,
)
def combine_album_track_metadata(
self, album_json: dict | None, track_json: dict
) -> dict:
@@ -288,10 +329,14 @@ class SRUtil:
"peak": track_json.get("peak"),
"lyrics": track_json.get("lyrics"),
"track_copyright": track_json.get("copyright"),
"cover_id": track_json.get("album", {}).get("cover") or album_json.get("cover"),
"cover_id": track_json.get("album", {}).get("cover")
or album_json.get("cover"),
"cover_url": (
f"https://resources.tidal.com/images/{track_json.get('album', {}).get('cover', album_json.get('cover'))}/1280x1280.jpg"
if (track_json.get("album", {}).get("cover") or album_json.get("cover"))
if (
track_json.get("album", {}).get("cover")
or album_json.get("cover")
)
else None
),
}
@@ -299,7 +344,6 @@ class SRUtil:
return combined
def combine_album_with_all_tracks(
self, album_json: dict[str, Any]
) -> list[dict[str, Any]]:
@@ -309,7 +353,9 @@ class SRUtil:
for t in album_json.get("tracks", [])
]
async def get_artists_by_name(self, artist_name: str, group: bool = False) -> Optional[list]:
async def get_artists_by_name(
self, artist_name: str, group: bool = False
) -> Optional[list]:
"""Get artist(s) by name.
Args:
@@ -324,7 +370,12 @@ class SRUtil:
delay = 1.0
for attempt in range(max_retries):
try:
artists = await self._safe_api_call(self.streamrip_client.search, media_type="artist", query=artist_name, retries=3)
artists = await self._safe_api_call(
self.streamrip_client.search,
media_type="artist",
query=artist_name,
retries=3,
)
break
except Exception as e:
msg = str(e)
@@ -344,7 +395,9 @@ class SRUtil:
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 []
artists_items = (
artists_page.get("items", []) if isinstance(artists_page, dict) else []
)
if not artists_items:
return None
artists_out = [
@@ -365,13 +418,19 @@ class SRUtil:
async def get_albums_by_artist_id(self, artist_id: int) -> Optional[list | dict]:
"""Get albums by artist ID. Retry login only on authentication failure. Rate limit and retry on 400/429."""
import asyncio
artist_id_str: str = str(artist_id)
albums_out: list[dict] = []
max_retries = 4
delay = 1.0
for attempt in range(max_retries):
try:
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, artist_id_str, "artist", retries=3)
metadata = await self._safe_api_call(
self.streamrip_client.get_metadata,
artist_id_str,
"artist",
retries=3,
)
break
except Exception as e:
msg = str(e)
@@ -397,10 +456,10 @@ class SRUtil:
if "title" in album and "id" in album and "artists" in album
]
return albums_out
async def get_album_by_name(self, artist: str, album: str) -> Optional[dict]:
"""Get album by artist and album name using artist ID and fuzzy matching. Try first 8 chars, then 12 if no match. Notify on success."""
# Notification moved to add_cover_art.py as requested
# Notification moved to add_cover_art.py as requested
for trunc in (8, 12):
search_artist = artist[:trunc]
artists = await self.get_artists_by_name(search_artist)
@@ -429,15 +488,22 @@ class SRUtil:
if best_album and best_album_score >= 85:
return best_album
return None
async def get_cover_by_album_id(self, album_id: int, size: int = 640) -> Optional[str]:
async def get_cover_by_album_id(
self, album_id: int, size: int = 640
) -> Optional[str]:
"""Get cover URL by album ID. Retry login only on authentication failure."""
if size not in [80, 160, 320, 640, 1280]:
return None
album_id_str: str = str(album_id)
for attempt in range(2):
try:
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, item_id=album_id_str, media_type="album", retries=2)
metadata = await self._safe_api_call(
self.streamrip_client.get_metadata,
item_id=album_id_str,
media_type="album",
retries=2,
)
break
except Exception:
if attempt == 1:
@@ -452,7 +518,6 @@ class SRUtil:
cover_url = self._get_tidal_cover_url(cover_id, size)
return cover_url
async def get_tracks_by_album_id(
self, album_id: int, quality: str = "FLAC"
) -> Optional[list | dict]:
@@ -464,7 +529,12 @@ class SRUtil:
"""
album_id_str = str(album_id)
try:
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, item_id=album_id_str, media_type="album", retries=2)
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
@@ -486,7 +556,9 @@ class SRUtil:
]
return tracks_out
async def get_tracks_by_artist_song(self, artist: str, song: str, n: int = 0) -> Optional[list]:
async def get_tracks_by_artist_song(
self, artist: str, song: str, n: int = 0
) -> Optional[list]:
"""Get track by artist and song name
Args:
artist (str): The name of the artist.
@@ -496,9 +568,18 @@ class SRUtil:
TODO: Reimplement using StreamRip
"""
try:
search_res = await self._safe_api_call(self.streamrip_client.search, media_type="track", query=f"{artist} - {song}", retries=3)
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') if search_res and isinstance(search_res, list) else []
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))
@@ -529,10 +610,15 @@ class SRUtil:
quality_int = 1
track_id_str: str = str(track_id)
# Ensure client is logged in via safe call when needed inside _safe_api_call
# 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._safe_api_call(self.streamrip_client.get_downloadable, track_id=track_id_str, quality=quality_int, retries=3)
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
@@ -557,7 +643,7 @@ class SRUtil:
metadata = await self.rate_limited_request(
self.streamrip_client.get_metadata, str(track_id), "track"
)
album_id = metadata.get("album", {}).get("id")
album_metadata = None
@@ -567,7 +653,11 @@ class SRUtil:
album_metadata = self.METADATA_ALBUM_CACHE[album_id]
else:
album_metadata = await self.rate_limited_request(
lambda i, t: self._safe_api_call(self.streamrip_client.get_metadata, i, t, retries=2), 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
@@ -611,11 +701,12 @@ class SRUtil:
self.MAX_METADATA_RETRIES,
)
# 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}")
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:
"""Download track
Args: