diff --git a/base.py b/base.py index 37e13ba..63951ea 100644 --- a/base.py +++ b/base.py @@ -17,6 +17,7 @@ except ImportError: from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, Request +from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware from scalar_fastapi import get_scalar_api_reference from lyric_search.sources import redis_cache @@ -110,7 +111,31 @@ app.add_middleware( # Scalar API documentation at /docs (replaces default Swagger UI) @app.get("/docs", include_in_schema=False) def scalar_docs(): - return get_scalar_api_reference(openapi_url="/openapi.json", title="codey.lol API") + # Replace default FastAPI favicon with site favicon + html_response = get_scalar_api_reference( + openapi_url="/openapi.json", title="codey.lol API" + ) + try: + body = ( + html_response.body.decode("utf-8") + if isinstance(html_response.body, (bytes, bytearray)) + else str(html_response.body) + ) + body = body.replace( + "https://fastapi.tiangolo.com/img/favicon.png", + "https://codey.lol/images/favicon.png", + ) + # Build fresh response so Content-Length matches modified body + return HTMLResponse(content=body, status_code=html_response.status_code) + except Exception: + # Fallback to original if anything goes wrong + return html_response + + +@app.get("/favicon.ico", include_in_schema=False) +async def favicon(): + """Redirect favicon requests to the site icon.""" + return RedirectResponse("https://codey.lol/images/favicon.png") """ diff --git a/endpoints/lighting.py b/endpoints/lighting.py index 5d45fec..6e19b07 100644 --- a/endpoints/lighting.py +++ b/endpoints/lighting.py @@ -228,6 +228,10 @@ class Lighting: if self._state.session.closed: return False + if not self._is_tcp_connected(): + logger.info("Cync TCP manager not connected; will reconnect") + return False + # Check token expiry if self._is_token_expired(): logger.info("Token expired or expiring soon") @@ -235,6 +239,35 @@ class Lighting: return True + def _is_tcp_connected(self) -> bool: + """Best-effort check that the pycync TCP connection is alive.""" + client = getattr(self._state.cync_api, "_command_client", None) + if not client: + return False + + tcp_manager = getattr(client, "_tcp_manager", None) + if not tcp_manager: + return False + + # If login was never acknowledged or was cleared, treat as disconnected + if not getattr(tcp_manager, "_login_acknowledged", False): + return False + + writer = getattr(tcp_manager, "_writer", None) + reader = getattr(tcp_manager, "_reader", None) + + # If underlying streams are closed, reconnect + if writer and writer.is_closing(): + return False + if reader and reader.at_eof(): + return False + + # Some versions expose a _closed flag + if getattr(tcp_manager, "_closed", False): + return False + + return True + def _is_token_expired(self) -> bool: """Check if token is expired or will expire soon.""" if not self._state.user: @@ -418,11 +451,21 @@ class Lighting: """Background task to monitor connection health and refresh tokens.""" while True: try: - await asyncio.sleep(300) # Check every 5 minutes + await asyncio.sleep(60) # Check every minute + + needs_reconnect = False # Proactively refresh if token is expiring if self._is_token_expired(): logger.info("Token expiring, proactively reconnecting...") + needs_reconnect = True + + # Reconnect if TCP connection looks dead + if not self._is_tcp_connected(): + logger.warning("Cync TCP connection lost; reconnecting...") + needs_reconnect = True + + if needs_reconnect: try: await self._connect(force=True) except Exception as e: diff --git a/endpoints/lyric_search.py b/endpoints/lyric_search.py index 683efc6..607123d 100644 --- a/endpoints/lyric_search.py +++ b/endpoints/lyric_search.py @@ -245,9 +245,9 @@ class LyricSearch(FastAPI): if i + line_count <= len(lyric_lines): # Combine consecutive lines with space separator combined_lines = [] - line_positions: list[tuple[int, int]] = ( - [] - ) # Track where each line starts in combined text + line_positions: list[ + tuple[int, int] + ] = [] # Track where each line starts in combined text combined_text_parts: list[str] = [] for j in range(line_count): diff --git a/endpoints/radio.py b/endpoints/radio.py index 8ab41fe..9a2a284 100644 --- a/endpoints/radio.py +++ b/endpoints/radio.py @@ -4,6 +4,7 @@ import time import random import json import asyncio +import socket from typing import Dict, Set from .constructors import ( ValidRadioNextRequest, @@ -33,6 +34,21 @@ from fastapi.responses import RedirectResponse, JSONResponse, FileResponse from auth.deps import get_current_user from collections import defaultdict + +def _get_local_ips() -> set[str]: + """Get all local IP addresses for this host.""" + ips = {"127.0.0.1", "::1"} + try: + for info in socket.getaddrinfo(socket.gethostname(), None): + ips.add(str(info[4][0])) + except Exception: + pass + return ips + + +_LOCAL_IPS = _get_local_ips() + + class Radio(FastAPI): """Radio Endpoints""" @@ -380,7 +396,6 @@ class Radio(FastAPI): data: ValidRadioNextRequest, request: Request, background_tasks: BackgroundTasks, - user=Depends(get_current_user), ) -> JSONResponse: """ Get the next track in the queue. The track will be removed from the queue in the process. @@ -395,8 +410,11 @@ class Radio(FastAPI): - **JSONResponse**: Contains the next track information. """ - if "dj" not in user.get("roles", []): - raise HTTPException(status_code=403, detail="Insufficient permissions") + try: + if request.client and request.client.host not in _LOCAL_IPS: + raise HTTPException(status_code=403, detail="Access denied") + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") logging.info("Radio get next") if data.station not in self.radio_util.active_playlist.keys(): diff --git a/endpoints/rip.py b/endpoints/rip.py index 0d05e43..9c142d3 100644 --- a/endpoints/rip.py +++ b/endpoints/rip.py @@ -5,6 +5,7 @@ from fastapi.responses import JSONResponse from utils.sr_wrapper import SRUtil from auth.deps import get_current_user from redis import Redis +from pathlib import Path from rq import Queue from rq.job import Job from rq.job import JobStatus @@ -20,8 +21,7 @@ from lyric_search.sources import private from typing import Literal from pydantic import BaseModel -logger = logging.getLogger() -logger.setLevel(logging.DEBUG) +logger = logging.getLogger(__name__) class ValidBulkFetchRequest(BaseModel): @@ -126,6 +126,22 @@ class RIP(FastAPI): ] ) + # Build detailed per-track list for the job detail response + raw_tracks = job.meta.get("tracks") or [] + track_list = [] + for t in raw_tracks: + # Normalize fields and pick the requested set + track_list.append( + { + "title": t.get("title"), + "artist": t.get("artist"), + "status": t.get("status"), + "error": t.get("error"), + "filename": t.get("filename") + or (Path(t.get("file_path")).name if t.get("file_path") else None), + } + ) + return { "id": job.id, "status": job_status.title(), @@ -140,6 +156,7 @@ class RIP(FastAPI): if isinstance(tracks_in, int) else tracks_out ), + "track_list": track_list, "target": job.meta.get("target"), "quality": job.meta.get("quality", "Unknown"), } diff --git a/lyric_search/models.py b/lyric_search/models.py index 2f04e87..68f3f81 100644 --- a/lyric_search/models.py +++ b/lyric_search/models.py @@ -99,9 +99,7 @@ POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "") # URL-encode the password to handle special characters encoded_password = urllib.parse.quote_plus(POSTGRES_PASSWORD) -DATABASE_URL: str = ( - f"postgresql+asyncpg://{POSTGRES_USER}:{encoded_password}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" -) +DATABASE_URL: str = f"postgresql+asyncpg://{POSTGRES_USER}:{encoded_password}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" async_engine: AsyncEngine = create_async_engine( DATABASE_URL, pool_size=20, max_overflow=10, pool_pre_ping=True, echo=False ) diff --git a/lyric_search/sources/cache.py b/lyric_search/sources/cache.py index cb80d9c..6887416 100644 --- a/lyric_search/sources/cache.py +++ b/lyric_search/sources/cache.py @@ -91,10 +91,8 @@ class Cache: logging.debug( "Checking whether %s is already stored", artistsong.replace("\n", " - ") ) - check_query: str = ( - 'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\ + check_query: str = 'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\ <= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1' - ) artistsong_split = artistsong.split("\n", maxsplit=1) artist = artistsong_split[0].lower() song = artistsong_split[1].lower() @@ -215,8 +213,10 @@ class Cache: lyrics = regex.sub(r"(
|\n|\r\n)", " / ", lyr_result.lyrics.strip()) lyrics = regex.sub(r"\s{2,}", " ", lyrics) - insert_query = "INSERT INTO lyrics (src, date_retrieved, artist, song, artistsong, confidence, lyrics)\ + insert_query = ( + "INSERT INTO lyrics (src, date_retrieved, artist, song, artistsong, confidence, lyrics)\ VALUES(?, ?, ?, ?, ?, ?, ?)" + ) params = ( lyr_result.src, time.time(), @@ -260,10 +260,8 @@ class Cache: if artist == "!" and song == "!": random_search = True - search_query: str = ( - "SELECT id, artist, song, lyrics, src, confidence\ + search_query: str = "SELECT id, artist, song, lyrics, src, confidence\ FROM lyrics ORDER BY RANDOM() LIMIT 1" - ) logging.info("Searching %s - %s on %s", artist, song, self.label) @@ -322,11 +320,9 @@ class Cache: self.cache_pre_query ) as _db_cursor: if not random_search: - search_query: str = ( - 'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\ + search_query: str = 'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\ WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\ <= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 10' - ) search_params: tuple = ( artist.strip(), song.strip(), diff --git a/lyric_search/sources/lrclib.py b/lyric_search/sources/lrclib.py index 7907629..fe62e8c 100644 --- a/lyric_search/sources/lrclib.py +++ b/lyric_search/sources/lrclib.py @@ -5,7 +5,7 @@ from sqlalchemy.future import select from lyric_search import utils from lyric_search.constructors import LyricsResult from lyric_search.models import Tracks, Lyrics, AsyncSessionLocal -from . import redis_cache +from . import redis_cache, cache logger = logging.getLogger() log_level = logging.getLevelName(logger.level) @@ -19,6 +19,7 @@ class LRCLib: self.datautils = utils.DataUtils() self.matcher = utils.TrackMatcher() self.redis_cache = redis_cache.RedisCache() + self.cache = cache.Cache() async def search( self, @@ -152,6 +153,9 @@ class LRCLib: ) await self.redis_cache.increment_found_count(self.label) + # Store plain lyrics to Redis cache (like Genius does) + if plain: + await self.cache.store(matched) return matched except Exception as e: diff --git a/lyric_search/utils.py b/lyric_search/utils.py index 743b9c6..2f874fa 100644 --- a/lyric_search/utils.py +++ b/lyric_search/utils.py @@ -111,8 +111,9 @@ class DataUtils: """ def __init__(self) -> None: - self.lrc_regex = regex.compile( # capture mm:ss and optional .xxx, then the lyric text - r""" + self.lrc_regex = ( + regex.compile( # capture mm:ss and optional .xxx, then the lyric text + r""" \[ # literal “[” ( # 1st (and only) capture group: [0-9]{2} # two-digit minutes @@ -123,7 +124,8 @@ class DataUtils: \s* # optional whitespace (.*) # capture the rest of the line as words """, - regex.VERBOSE, + regex.VERBOSE, + ) ) self.scrub_regex_1: Pattern = regex.compile(r"(\[.*?\])(\s){0,}(\:){0,1}") self.scrub_regex_2: Pattern = regex.compile( diff --git a/shared.py b/shared.py index 62a5426..319a1f6 100644 --- a/shared.py +++ b/shared.py @@ -92,7 +92,11 @@ def get_redis_sync_client(decode_responses: bool = True) -> redis_sync.Redis: async def close_redis_pools() -> None: """Close Redis connections. Call on app shutdown.""" - global _redis_async_pool, _redis_async_client, _redis_sync_client, _redis_sync_client_decoded + global \ + _redis_async_pool, \ + _redis_async_client, \ + _redis_sync_client, \ + _redis_sync_client_decoded if _redis_async_client: await _redis_async_client.close() diff --git a/utils/meme_util.py b/utils/meme_util.py index 087e497..d2061ba 100644 --- a/utils/meme_util.py +++ b/utils/meme_util.py @@ -127,9 +127,7 @@ class MemeUtil: db_conn.row_factory = sqlite3.Row rows_per_page: int = 10 offset: int = (page - 1) * rows_per_page - query: str = ( - "SELECT id, timestamp FROM memes ORDER BY timestamp DESC LIMIT 10 OFFSET ?" - ) + query: str = "SELECT id, timestamp FROM memes ORDER BY timestamp DESC LIMIT 10 OFFSET ?" async with await db_conn.execute(query, (offset,)) as db_cursor: results = await db_cursor.fetchall() for result in results: diff --git a/utils/radio_util.py b/utils/radio_util.py index 4abe163..f030457 100644 --- a/utils/radio_util.py +++ b/utils/radio_util.py @@ -5,6 +5,7 @@ import datetime import os import random import asyncio +import subprocess from uuid import uuid4 as uuid from typing import Union, Optional, Iterable from aiohttp import ClientSession, ClientTimeout @@ -391,6 +392,39 @@ class RadioUtil: traceback.print_exc() return "Not Found" + async def _restart_liquidsoap_when_ready(self) -> None: + """Poll server until responsive, then restart Liquidsoap.""" + max_attempts = 60 + for attempt in range(max_attempts): + try: + async with ClientSession() as session: + async with session.get( + "http://127.0.0.1:52111/", + timeout=ClientTimeout(total=3), + ) as resp: + logging.debug("Server check attempt %d: status %d", attempt + 1, resp.status) + if resp.status < 500: + logging.info("Server is ready (attempt %d)", attempt + 1) + break + except Exception as e: + logging.debug("Server check attempt %d failed: %s", attempt + 1, str(e)) + await asyncio.sleep(1) + else: + logging.warning("Server readiness check timed out, restarting Liquidsoap anyway") + + try: + logging.info("Restarting Liquidsoap...") + subprocess.Popen( + ["./restart.sh"], + cwd="/home/kyle/ls", + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + logging.info("Liquidsoap restart initiated") + except Exception as e: + logging.error("Error starting Liquidsoap restart: %s", str(e)) + async def load_playlists(self) -> None: """Load Playlists""" try: @@ -487,10 +521,8 @@ class RadioUtil: """Loading Complete""" self.playlists_loaded = True - # Request skip from LS to bring streams current - for playlist in self.playlists: - logging.info("Skipping: %s", playlist) - await self._ls_skip(playlist) + # Restart Liquidsoap once server is responsive (fire and forget) + asyncio.create_task(self._restart_liquidsoap_when_ready()) except Exception as e: logging.info("Playlist load failed: %s", str(e)) traceback.print_exc() diff --git a/utils/rip_background.py b/utils/rip_background.py index ac048f3..79d8a96 100644 --- a/utils/rip_background.py +++ b/utils/rip_background.py @@ -9,7 +9,6 @@ import subprocess import shutil from pathlib import Path from typing import Optional -from urllib.parse import urlparse, unquote import aiohttp from datetime import datetime, timezone from mediafile import MediaFile, Image, ImageType # type: ignore[import] @@ -20,9 +19,9 @@ import re # ---------- Config ---------- ROOT_DIR = Path("/storage/music2") -MAX_RETRIES = 5 -THROTTLE_MIN = 1.0 -THROTTLE_MAX = 3.5 +MAX_RETRIES = 4 +THROTTLE_MIN = 0.0 +THROTTLE_MAX = 0.0 DISCORD_WEBHOOK = os.getenv("TRIP_WEBHOOK_URI", "").strip() HEADERS = { @@ -36,10 +35,7 @@ HEADERS = { "Connection": "keep-alive", } -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", -) +# Logging is configured in base.py - don't override here load_dotenv() @@ -288,8 +284,8 @@ def bulk_download(track_list: list, quality: str = "FLAC"): all_artists = set() (ROOT_DIR / "completed").mkdir(parents=True, exist_ok=True) - # Ensure aiohttp session is properly closed - async with aiohttp.ClientSession(headers=HEADERS) as session: + session = aiohttp.ClientSession(headers=HEADERS) + try: print(f"DEBUG: Starting process_tracks with {len(track_list)} tracks") # Set up a one-time rate-limit callback to notify on the first 429 seen by SRUtil @@ -314,13 +310,57 @@ def bulk_download(track_list: list, quality: str = "FLAC"): print(f"DEBUG: Processing track {i + 1}/{total}: {track_id}") track_info = { "track_id": str(track_id), + "title": None, + "artist": None, "status": "Pending", "file_path": None, + "filename": None, "error": None, "attempts": 0, } attempt = 0 + # Fetch metadata FIRST to check if track is available before attempting download + md = None + try: + print(f"DEBUG: Fetching metadata for track {track_id}") + md = await sr.get_metadata_by_track_id(track_id) or {} + print(f"DEBUG: Metadata fetched: {bool(md)}") + + # Check if track is streamable + if md and not md.get("streamable", True): + print(f"TRACK {track_id}: Not streamable, skipping") + track_info["status"] = "Failed" + track_info["error"] = "Track not streamable" + track_info["title"] = md.get("title") or f"Track {track_id}" + track_info["artist"] = md.get("artist") or "Unknown Artist" + per_track_meta.append(track_info) + if job: + job.meta["tracks"] = per_track_meta + job.meta["progress"] = int(((i + 1) / total) * 100) + job.save_meta() + continue # Skip to next track + + except MetadataFetchError as me: + # Permanent metadata failure — mark failed and skip + print(f"TRACK {track_id}: Metadata fetch failed permanently: {me}") + track_info["status"] = "Failed" + track_info["error"] = str(me) + track_info["title"] = f"Track {track_id}" + track_info["artist"] = "Unknown Artist" + per_track_meta.append(track_info) + if job: + job.meta["tracks"] = per_track_meta + job.meta["progress"] = int(((i + 1) / total) * 100) + job.save_meta() + continue # Skip to next track + except Exception as meta_err: + # Non-permanent error - will retry during download attempts + print( + f"TRACK {track_id}: Metadata prefetch failed (will retry): {meta_err}" + ) + md = None + while attempt < MAX_RETRIES: tmp_file = None attempt += 1 @@ -367,21 +407,13 @@ def bulk_download(track_list: list, quality: str = "FLAC"): f"Download completed but no file created: {tmp_file}" ) - print(f"DEBUG: Fetching metadata for track {track_id}") - # Metadata fetch - try: - md = await sr.get_metadata_by_track_id(track_id) or {} - print(f"DEBUG: Metadata fetched: {bool(md)}") - except MetadataFetchError as me: - # Permanent metadata failure — mark failed and break - track_info["status"] = "Failed" - track_info["error"] = str(me) - per_track_meta.append(track_info) - if job: - job.meta["tracks"] = per_track_meta - job.meta["progress"] = int(((i + 1) / total) * 100) - job.save_meta() - break + # If we didn't get metadata earlier, try again now + if not md: + print(f"DEBUG: Re-fetching metadata for track {track_id}") + try: + md = await sr.get_metadata_by_track_id(track_id) or {} + except Exception: + md = {} artist_raw = md.get("artist") or "Unknown Artist" album_raw = md.get("album") or "Unknown Album" @@ -391,6 +423,10 @@ def bulk_download(track_list: list, quality: str = "FLAC"): album = sanitize_filename(album_raw) title = sanitize_filename(title_raw) + # Populate track_info fields so job meta contains the user-visible data + track_info["title"] = title + track_info["artist"] = artist + print(f"TRACK {track_id}: Processing '{title}' by {artist}") all_artists.add(artist) @@ -400,7 +436,7 @@ def bulk_download(track_list: list, quality: str = "FLAC"): # Move to final location print(f"TRACK {track_id}: Moving to final location...") - tmp_file.rename(final_file) + shutil.move(str(tmp_file), str(final_file)) print(f"TRACK {track_id}: File moved successfully") # Fetch cover art @@ -507,6 +543,10 @@ def bulk_download(track_list: list, quality: str = "FLAC"): tmp_file = None track_info["status"] = "Success" track_info["file_path"] = str(final_file) + try: + track_info["filename"] = final_file.name + except Exception: + track_info["filename"] = None track_info["error"] = None all_final_files.append(final_file) @@ -514,6 +554,9 @@ def bulk_download(track_list: list, quality: str = "FLAC"): f"TRACK {track_id}: SUCCESS! Progress: {((i + 1) / total) * 100:.0f}%" ) + # Throttle after successful download to avoid hitting server too quickly + await asyncio.sleep(random.uniform(THROTTLE_MIN, THROTTLE_MAX)) + if job: job.meta["progress"] = int(((i + 1) / total) * 100) job.meta["tracks"] = per_track_meta + [track_info] @@ -523,9 +566,34 @@ def bulk_download(track_list: list, quality: str = "FLAC"): except aiohttp.ClientResponseError as e: msg = f"Track {track_id} attempt {attempt} ClientResponseError: {e}" send_log_to_discord(msg, "WARNING", target) + # If 429, backoff as before. If 5xx, recreate session and refresh Tidal client. if getattr(e, "status", None) == 429: wait_time = min(60, 2**attempt) await asyncio.sleep(wait_time) + elif 500 <= getattr(e, "status", 0) < 600: + # Recreate local aiohttp session on 5xx errors + try: + await session.close() + except Exception: + pass + session = aiohttp.ClientSession(headers=HEADERS) + # Also force a fresh Tidal login in case the upstream session is stale + try: + await sr._force_fresh_login() + send_log_to_discord( + f"Refreshed Tidal session after 5xx error on track {track_id}", + "WARNING", + target, + ) + except Exception as login_err: + send_log_to_discord( + f"Failed to refresh Tidal session: {login_err}", + "ERROR", + target, + ) + await asyncio.sleep( + random.uniform(THROTTLE_MIN, THROTTLE_MAX) + ) else: await asyncio.sleep( random.uniform(THROTTLE_MIN, THROTTLE_MAX) @@ -533,10 +601,74 @@ def bulk_download(track_list: list, quality: str = "FLAC"): except Exception as e: tb = traceback.format_exc() + err_str = str(e).lower() is_no_stream_url = ( isinstance(e, RuntimeError) and str(e) == "No stream URL" ) - if is_no_stream_url: + # Check if this is a 5xx error from the server (may appear in error message) + is_5xx_error = any( + code in err_str for code in ("500", "502", "503", "504") + ) + # Check for permanent failures that should NOT be retried + is_not_found = any( + phrase in err_str + for phrase in ( + "track not found", + "not found", + "404", + "does not exist", + "no longer available", + "asset is not ready", + ) + ) + + if is_not_found: + # Permanent failure - do not retry + msg = ( + f"Track {track_id} not found/unavailable, skipping: {e}" + ) + print(msg) + send_log_to_discord(msg, "WARNING", target) + track_info["status"] = "Failed" + track_info["error"] = str(e) + break # Exit retry loop immediately + elif is_5xx_error: + msg = ( + f"Track {track_id} attempt {attempt} server error: {e}" + ) + send_log_to_discord(msg, "WARNING", target) + track_info["error"] = err_str + # Recreate local aiohttp session + try: + await session.close() + except Exception: + pass + session = aiohttp.ClientSession(headers=HEADERS) + # Force a fresh Tidal login + try: + await sr._force_fresh_login() + send_log_to_discord( + f"Refreshed Tidal session after 5xx error on track {track_id}", + "WARNING", + target, + ) + except Exception as login_err: + send_log_to_discord( + f"Failed to refresh Tidal session: {login_err}", + "ERROR", + target, + ) + if attempt >= MAX_RETRIES: + track_info["status"] = "Failed" + send_log_to_discord( + f"Track {track_id} failed after {attempt} attempts (5xx)", + "ERROR", + target, + ) + await asyncio.sleep( + random.uniform(THROTTLE_MIN, THROTTLE_MAX) + ) + elif is_no_stream_url: if attempt == 1 or attempt == MAX_RETRIES: msg = f"Track {track_id} attempt {attempt} failed: {e}\n{tb}" send_log_to_discord(msg, "ERROR", target) @@ -575,8 +707,22 @@ def bulk_download(track_list: list, quality: str = "FLAC"): except Exception: pass + # Ensure placeholders and filename for the job metadata + track_info["title"] = track_info.get("title") or f"Track {track_id}" + track_info["artist"] = track_info.get("artist") or "Unknown Artist" + if track_info.get("file_path") and not track_info.get("filename"): + try: + track_info["filename"] = Path(track_info["file_path"]).name + except Exception: + track_info["filename"] = None per_track_meta.append(track_info) + finally: + try: + await session.close() + except Exception: + pass + if not all_final_files: if job: job.meta["tarball"] = None @@ -624,7 +770,7 @@ def bulk_download(track_list: list, quality: str = "FLAC"): counter += 1 staged_tarball = staging_root / f"{base_name} ({counter}).tar.gz" - final_dir = ROOT_DIR / "completed" / quality + final_dir = Path("/storage/music/TRIP") final_dir.mkdir(parents=True, exist_ok=True) # Ensure we don't overwrite an existing final tarball. Preserve `.tar.gz` style. final_tarball = ensure_unique_filename_in_dir(final_dir, staged_tarball.name) @@ -677,6 +823,14 @@ def bulk_download(track_list: list, quality: str = "FLAC"): os.remove(f) except Exception: pass + except Exception as e: + send_log_to_discord(f"Tar creation failed: {e}", "ERROR", target) + if job: + job.meta["status"] = "compress_failed" + job.save_meta() + # Do not proceed further if tarball creation failed + await asyncio.sleep(0.1) + return [] if not staged_tarball.exists(): send_log_to_discord( @@ -711,6 +865,9 @@ def bulk_download(track_list: list, quality: str = "FLAC"): color=0x00FF00, ) + # Always log the final tarball path for debugging + logging.info("Job %s finished, tarball: %s", job_id, final_tarball) + return [str(final_tarball)] loop = asyncio.new_event_loop() diff --git a/utils/sr_wrapper.py b/utils/sr_wrapper.py index f27448f..5f405cf 100644 --- a/utils/sr_wrapper.py +++ b/utils/sr_wrapper.py @@ -1081,6 +1081,27 @@ class SRUtil: return combined_metadata except Exception as e: + err_str = str(e).lower() + # If this is a permanent not found error, abort retries immediately + if any( + phrase in err_str + for phrase in [ + "track not found", + "not found", + "404", + "does not exist", + "no longer available", + "asset is not ready", + ] + ): + logging.error( + "Metadata fetch permanent failure for track %s: %s (not retrying)", + track_id, + str(e), + ) + raise MetadataFetchError( + f"Metadata fetch failed permanently for track {track_id}: {e}" + ) # Exponential backoff with jitter for 429 or other errors delay = self.RETRY_DELAY * (2 ** (attempt - 1)) + random.uniform(0, 0.5) if attempt < self.MAX_METADATA_RETRIES: @@ -1179,6 +1200,47 @@ class SRUtil: if not tracks: return None + # Prefer exact title matches first (highest confidence) + exact_title_matches = [] + for t in tracks: + found_title = t.get("title") + if found_title and found_title.strip().lower() == song.strip().lower(): + exact_title_matches.append(t) + if exact_title_matches: + logging.info(f"SR: {len(exact_title_matches)} exact title matches found") + tracks = exact_title_matches + else: + # Prefer tracks that match artist/title fuzzily + filtered_by_metadata = [] + for t in tracks: + found_artist = ( + t.get("artist", {}).get("name") + if isinstance(t.get("artist"), dict) + else t.get("artist") + ) + found_album = ( + t.get("album", {}).get("title") if t.get("album") else None + ) + found_title = t.get("title") + try: + if self.is_metadata_match( + artist, album, song, found_artist, found_album, found_title + ): + filtered_by_metadata.append(t) + except Exception: + # On any error, skip strict metadata matching for this candidate + continue + + if filtered_by_metadata: + logging.info( + f"SR: {len(filtered_by_metadata)} candidates after metadata filtering" + ) + tracks = filtered_by_metadata + else: + logging.info( + "SR: No candidates passed metadata match filter; falling back to search results" + ) + # If duration provided, select the track with closest duration match if duration is not None: tracks_with_diff = [ @@ -1195,7 +1257,88 @@ class SRUtil: best_track = tracks[0] track_id = best_track.get("id") - logging.info(f"SR: Using track ID {track_id}") + # Ensure the selected candidate reasonably matches expected metadata + selected_artist = ( + best_track.get("artist", {}).get("name") + if isinstance(best_track.get("artist"), dict) + else best_track.get("artist") + ) + selected_title = best_track.get("title") + if not self.is_metadata_match( + artist, + album, + song, + selected_artist, + best_track.get("album", {}).get("title") + if best_track.get("album") + else None, + selected_title, + ): + # Try to find another candidate that does match metadata + logging.warning( + "SR: Selected candidate failed metadata check: id=%s artist=%s title=%s; searching for better match", + track_id, + selected_artist, + selected_title, + ) + found_better = None + for candidate in tracks: + cand_artist = ( + candidate.get("artist", {}).get("name") + if isinstance(candidate.get("artist"), dict) + else candidate.get("artist") + ) + cand_title = candidate.get("title") + if self.is_metadata_match( + artist, + album, + song, + cand_artist, + candidate.get("album", {}).get("title") + if candidate.get("album") + else None, + cand_title, + ): + found_better = candidate + break + if found_better: + logging.warning( + "SR: Switching to better candidate id=%s artist=%s title=%s", + found_better.get("id"), + ( + found_better.get("artist", {}).get("name") + if isinstance(found_better.get("artist"), dict) + else found_better.get("artist") + ), + found_better.get("title"), + ) + best_track = found_better + track_id = best_track.get("id") + else: + # No matching candidate passed metadata checks; log candidates and abort + logging.warning( + "SR: No candidates passed metadata checks for %s - %s; candidates: %s", + artist, + song, + [ + { + "id": t.get("id"), + "artist": ( + t.get("artist", {}).get("name") + if isinstance(t.get("artist"), dict) + else t.get("artist") + ), + "title": t.get("title"), + "duration": t.get("duration"), + } + for t in tracks[:10] + ], + ) + return None + + logging.info( + f"SR: Using track ID {track_id} (artist={best_track.get('artist')}, title={best_track.get('title')})" + ) if not track_id: return None