various/stale

This commit is contained in:
2026-01-25 13:14:00 -05:00
parent 10ccf8c8eb
commit 97fd7dd67d
14 changed files with 501 additions and 64 deletions

27
base.py
View File

@@ -17,6 +17,7 @@ except ImportError:
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from scalar_fastapi import get_scalar_api_reference from scalar_fastapi import get_scalar_api_reference
from lyric_search.sources import redis_cache from lyric_search.sources import redis_cache
@@ -110,7 +111,31 @@ app.add_middleware(
# Scalar API documentation at /docs (replaces default Swagger UI) # Scalar API documentation at /docs (replaces default Swagger UI)
@app.get("/docs", include_in_schema=False) @app.get("/docs", include_in_schema=False)
def scalar_docs(): 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")
""" """

View File

@@ -228,6 +228,10 @@ class Lighting:
if self._state.session.closed: if self._state.session.closed:
return False return False
if not self._is_tcp_connected():
logger.info("Cync TCP manager not connected; will reconnect")
return False
# Check token expiry # Check token expiry
if self._is_token_expired(): if self._is_token_expired():
logger.info("Token expired or expiring soon") logger.info("Token expired or expiring soon")
@@ -235,6 +239,35 @@ class Lighting:
return True 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: def _is_token_expired(self) -> bool:
"""Check if token is expired or will expire soon.""" """Check if token is expired or will expire soon."""
if not self._state.user: if not self._state.user:
@@ -418,11 +451,21 @@ class Lighting:
"""Background task to monitor connection health and refresh tokens.""" """Background task to monitor connection health and refresh tokens."""
while True: while True:
try: 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 # Proactively refresh if token is expiring
if self._is_token_expired(): if self._is_token_expired():
logger.info("Token expiring, proactively reconnecting...") 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: try:
await self._connect(force=True) await self._connect(force=True)
except Exception as e: except Exception as e:

View File

@@ -245,9 +245,9 @@ class LyricSearch(FastAPI):
if i + line_count <= len(lyric_lines): if i + line_count <= len(lyric_lines):
# Combine consecutive lines with space separator # Combine consecutive lines with space separator
combined_lines = [] combined_lines = []
line_positions: list[tuple[int, int]] = ( line_positions: list[
[] tuple[int, int]
) # Track where each line starts in combined text ] = [] # Track where each line starts in combined text
combined_text_parts: list[str] = [] combined_text_parts: list[str] = []
for j in range(line_count): for j in range(line_count):

View File

@@ -4,6 +4,7 @@ import time
import random import random
import json import json
import asyncio import asyncio
import socket
from typing import Dict, Set from typing import Dict, Set
from .constructors import ( from .constructors import (
ValidRadioNextRequest, ValidRadioNextRequest,
@@ -33,6 +34,21 @@ from fastapi.responses import RedirectResponse, JSONResponse, FileResponse
from auth.deps import get_current_user from auth.deps import get_current_user
from collections import defaultdict 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): class Radio(FastAPI):
"""Radio Endpoints""" """Radio Endpoints"""
@@ -380,7 +396,6 @@ class Radio(FastAPI):
data: ValidRadioNextRequest, data: ValidRadioNextRequest,
request: Request, request: Request,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
user=Depends(get_current_user),
) -> JSONResponse: ) -> JSONResponse:
""" """
Get the next track in the queue. The track will be removed from the queue in the process. 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. - **JSONResponse**: Contains the next track information.
""" """
if "dj" not in user.get("roles", []): try:
raise HTTPException(status_code=403, detail="Insufficient permissions") 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") logging.info("Radio get next")
if data.station not in self.radio_util.active_playlist.keys(): if data.station not in self.radio_util.active_playlist.keys():

View File

@@ -5,6 +5,7 @@ from fastapi.responses import JSONResponse
from utils.sr_wrapper import SRUtil from utils.sr_wrapper import SRUtil
from auth.deps import get_current_user from auth.deps import get_current_user
from redis import Redis from redis import Redis
from pathlib import Path
from rq import Queue from rq import Queue
from rq.job import Job from rq.job import Job
from rq.job import JobStatus from rq.job import JobStatus
@@ -20,8 +21,7 @@ from lyric_search.sources import private
from typing import Literal from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel
logger = logging.getLogger() logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class ValidBulkFetchRequest(BaseModel): 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 { return {
"id": job.id, "id": job.id,
"status": job_status.title(), "status": job_status.title(),
@@ -140,6 +156,7 @@ class RIP(FastAPI):
if isinstance(tracks_in, int) if isinstance(tracks_in, int)
else tracks_out else tracks_out
), ),
"track_list": track_list,
"target": job.meta.get("target"), "target": job.meta.get("target"),
"quality": job.meta.get("quality", "Unknown"), "quality": job.meta.get("quality", "Unknown"),
} }

View File

@@ -99,9 +99,7 @@ POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "")
# URL-encode the password to handle special characters # URL-encode the password to handle special characters
encoded_password = urllib.parse.quote_plus(POSTGRES_PASSWORD) encoded_password = urllib.parse.quote_plus(POSTGRES_PASSWORD)
DATABASE_URL: str = ( DATABASE_URL: str = f"postgresql+asyncpg://{POSTGRES_USER}:{encoded_password}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
f"postgresql+asyncpg://{POSTGRES_USER}:{encoded_password}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
)
async_engine: AsyncEngine = create_async_engine( async_engine: AsyncEngine = create_async_engine(
DATABASE_URL, pool_size=20, max_overflow=10, pool_pre_ping=True, echo=False DATABASE_URL, pool_size=20, max_overflow=10, pool_pre_ping=True, echo=False
) )

View File

@@ -91,10 +91,8 @@ class Cache:
logging.debug( logging.debug(
"Checking whether %s is already stored", artistsong.replace("\n", " - ") "Checking whether %s is already stored", artistsong.replace("\n", " - ")
) )
check_query: str = ( check_query: str = 'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1' <= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1'
)
artistsong_split = artistsong.split("\n", maxsplit=1) artistsong_split = artistsong.split("\n", maxsplit=1)
artist = artistsong_split[0].lower() artist = artistsong_split[0].lower()
song = artistsong_split[1].lower() song = artistsong_split[1].lower()
@@ -215,8 +213,10 @@ class Cache:
lyrics = regex.sub(r"(<br>|\n|\r\n)", " / ", lyr_result.lyrics.strip()) lyrics = regex.sub(r"(<br>|\n|\r\n)", " / ", lyr_result.lyrics.strip())
lyrics = regex.sub(r"\s{2,}", " ", lyrics) 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(?, ?, ?, ?, ?, ?, ?)" VALUES(?, ?, ?, ?, ?, ?, ?)"
)
params = ( params = (
lyr_result.src, lyr_result.src,
time.time(), time.time(),
@@ -260,10 +260,8 @@ class Cache:
if artist == "!" and song == "!": if artist == "!" and song == "!":
random_search = True random_search = True
search_query: str = ( search_query: str = "SELECT id, artist, song, lyrics, src, confidence\
"SELECT id, artist, song, lyrics, src, confidence\
FROM lyrics ORDER BY RANDOM() LIMIT 1" FROM lyrics ORDER BY RANDOM() LIMIT 1"
)
logging.info("Searching %s - %s on %s", artist, song, self.label) logging.info("Searching %s - %s on %s", artist, song, self.label)
@@ -322,11 +320,9 @@ class Cache:
self.cache_pre_query self.cache_pre_query
) as _db_cursor: ) as _db_cursor:
if not random_search: if not random_search:
search_query: str = ( search_query: str = 'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\
'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\
WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\ WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 10' <= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 10'
)
search_params: tuple = ( search_params: tuple = (
artist.strip(), artist.strip(),
song.strip(), song.strip(),

View File

@@ -5,7 +5,7 @@ from sqlalchemy.future import select
from lyric_search import utils from lyric_search import utils
from lyric_search.constructors import LyricsResult from lyric_search.constructors import LyricsResult
from lyric_search.models import Tracks, Lyrics, AsyncSessionLocal from lyric_search.models import Tracks, Lyrics, AsyncSessionLocal
from . import redis_cache from . import redis_cache, cache
logger = logging.getLogger() logger = logging.getLogger()
log_level = logging.getLevelName(logger.level) log_level = logging.getLevelName(logger.level)
@@ -19,6 +19,7 @@ class LRCLib:
self.datautils = utils.DataUtils() self.datautils = utils.DataUtils()
self.matcher = utils.TrackMatcher() self.matcher = utils.TrackMatcher()
self.redis_cache = redis_cache.RedisCache() self.redis_cache = redis_cache.RedisCache()
self.cache = cache.Cache()
async def search( async def search(
self, self,
@@ -152,6 +153,9 @@ class LRCLib:
) )
await self.redis_cache.increment_found_count(self.label) 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 return matched
except Exception as e: except Exception as e:

View File

@@ -111,8 +111,9 @@ class DataUtils:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.lrc_regex = regex.compile( # capture mm:ss and optional .xxx, then the lyric text self.lrc_regex = (
r""" regex.compile( # capture mm:ss and optional .xxx, then the lyric text
r"""
\[ # literal “[” \[ # literal “[”
( # 1st (and only) capture group: ( # 1st (and only) capture group:
[0-9]{2} # two-digit minutes [0-9]{2} # two-digit minutes
@@ -123,7 +124,8 @@ class DataUtils:
\s* # optional whitespace \s* # optional whitespace
(.*) # capture the rest of the line as words (.*) # 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_1: Pattern = regex.compile(r"(\[.*?\])(\s){0,}(\:){0,1}")
self.scrub_regex_2: Pattern = regex.compile( self.scrub_regex_2: Pattern = regex.compile(

View File

@@ -92,7 +92,11 @@ def get_redis_sync_client(decode_responses: bool = True) -> redis_sync.Redis:
async def close_redis_pools() -> None: async def close_redis_pools() -> None:
"""Close Redis connections. Call on app shutdown.""" """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: if _redis_async_client:
await _redis_async_client.close() await _redis_async_client.close()

View File

@@ -127,9 +127,7 @@ class MemeUtil:
db_conn.row_factory = sqlite3.Row db_conn.row_factory = sqlite3.Row
rows_per_page: int = 10 rows_per_page: int = 10
offset: int = (page - 1) * rows_per_page offset: int = (page - 1) * rows_per_page
query: str = ( query: str = "SELECT id, timestamp FROM memes ORDER BY timestamp DESC LIMIT 10 OFFSET ?"
"SELECT id, timestamp FROM memes ORDER BY timestamp DESC LIMIT 10 OFFSET ?"
)
async with await db_conn.execute(query, (offset,)) as db_cursor: async with await db_conn.execute(query, (offset,)) as db_cursor:
results = await db_cursor.fetchall() results = await db_cursor.fetchall()
for result in results: for result in results:

View File

@@ -5,6 +5,7 @@ import datetime
import os import os
import random import random
import asyncio import asyncio
import subprocess
from uuid import uuid4 as uuid from uuid import uuid4 as uuid
from typing import Union, Optional, Iterable from typing import Union, Optional, Iterable
from aiohttp import ClientSession, ClientTimeout from aiohttp import ClientSession, ClientTimeout
@@ -391,6 +392,39 @@ class RadioUtil:
traceback.print_exc() traceback.print_exc()
return "Not Found" 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: async def load_playlists(self) -> None:
"""Load Playlists""" """Load Playlists"""
try: try:
@@ -487,10 +521,8 @@ class RadioUtil:
"""Loading Complete""" """Loading Complete"""
self.playlists_loaded = True self.playlists_loaded = True
# Request skip from LS to bring streams current # Restart Liquidsoap once server is responsive (fire and forget)
for playlist in self.playlists: asyncio.create_task(self._restart_liquidsoap_when_ready())
logging.info("Skipping: %s", playlist)
await self._ls_skip(playlist)
except Exception as e: except Exception as e:
logging.info("Playlist load failed: %s", str(e)) logging.info("Playlist load failed: %s", str(e))
traceback.print_exc() traceback.print_exc()

View File

@@ -9,7 +9,6 @@ import subprocess
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import urlparse, unquote
import aiohttp import aiohttp
from datetime import datetime, timezone from datetime import datetime, timezone
from mediafile import MediaFile, Image, ImageType # type: ignore[import] from mediafile import MediaFile, Image, ImageType # type: ignore[import]
@@ -20,9 +19,9 @@ import re
# ---------- Config ---------- # ---------- Config ----------
ROOT_DIR = Path("/storage/music2") ROOT_DIR = Path("/storage/music2")
MAX_RETRIES = 5 MAX_RETRIES = 4
THROTTLE_MIN = 1.0 THROTTLE_MIN = 0.0
THROTTLE_MAX = 3.5 THROTTLE_MAX = 0.0
DISCORD_WEBHOOK = os.getenv("TRIP_WEBHOOK_URI", "").strip() DISCORD_WEBHOOK = os.getenv("TRIP_WEBHOOK_URI", "").strip()
HEADERS = { HEADERS = {
@@ -36,10 +35,7 @@ HEADERS = {
"Connection": "keep-alive", "Connection": "keep-alive",
} }
logging.basicConfig( # Logging is configured in base.py - don't override here
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
load_dotenv() load_dotenv()
@@ -288,8 +284,8 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
all_artists = set() all_artists = set()
(ROOT_DIR / "completed").mkdir(parents=True, exist_ok=True) (ROOT_DIR / "completed").mkdir(parents=True, exist_ok=True)
# Ensure aiohttp session is properly closed session = aiohttp.ClientSession(headers=HEADERS)
async with aiohttp.ClientSession(headers=HEADERS) as session: try:
print(f"DEBUG: Starting process_tracks with {len(track_list)} tracks") 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 # 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}") print(f"DEBUG: Processing track {i + 1}/{total}: {track_id}")
track_info = { track_info = {
"track_id": str(track_id), "track_id": str(track_id),
"title": None,
"artist": None,
"status": "Pending", "status": "Pending",
"file_path": None, "file_path": None,
"filename": None,
"error": None, "error": None,
"attempts": 0, "attempts": 0,
} }
attempt = 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: while attempt < MAX_RETRIES:
tmp_file = None tmp_file = None
attempt += 1 attempt += 1
@@ -367,21 +407,13 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
f"Download completed but no file created: {tmp_file}" f"Download completed but no file created: {tmp_file}"
) )
print(f"DEBUG: Fetching metadata for track {track_id}") # If we didn't get metadata earlier, try again now
# Metadata fetch if not md:
try: print(f"DEBUG: Re-fetching metadata for track {track_id}")
md = await sr.get_metadata_by_track_id(track_id) or {} try:
print(f"DEBUG: Metadata fetched: {bool(md)}") md = await sr.get_metadata_by_track_id(track_id) or {}
except MetadataFetchError as me: except Exception:
# Permanent metadata failure — mark failed and break md = {}
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
artist_raw = md.get("artist") or "Unknown Artist" artist_raw = md.get("artist") or "Unknown Artist"
album_raw = md.get("album") or "Unknown Album" 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) album = sanitize_filename(album_raw)
title = sanitize_filename(title_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}") print(f"TRACK {track_id}: Processing '{title}' by {artist}")
all_artists.add(artist) all_artists.add(artist)
@@ -400,7 +436,7 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
# Move to final location # Move to final location
print(f"TRACK {track_id}: Moving 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") print(f"TRACK {track_id}: File moved successfully")
# Fetch cover art # Fetch cover art
@@ -507,6 +543,10 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
tmp_file = None tmp_file = None
track_info["status"] = "Success" track_info["status"] = "Success"
track_info["file_path"] = str(final_file) track_info["file_path"] = str(final_file)
try:
track_info["filename"] = final_file.name
except Exception:
track_info["filename"] = None
track_info["error"] = None track_info["error"] = None
all_final_files.append(final_file) 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}%" 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: if job:
job.meta["progress"] = int(((i + 1) / total) * 100) job.meta["progress"] = int(((i + 1) / total) * 100)
job.meta["tracks"] = per_track_meta + [track_info] 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: except aiohttp.ClientResponseError as e:
msg = f"Track {track_id} attempt {attempt} ClientResponseError: {e}" msg = f"Track {track_id} attempt {attempt} ClientResponseError: {e}"
send_log_to_discord(msg, "WARNING", target) 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: if getattr(e, "status", None) == 429:
wait_time = min(60, 2**attempt) wait_time = min(60, 2**attempt)
await asyncio.sleep(wait_time) 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: else:
await asyncio.sleep( await asyncio.sleep(
random.uniform(THROTTLE_MIN, THROTTLE_MAX) random.uniform(THROTTLE_MIN, THROTTLE_MAX)
@@ -533,10 +601,74 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
except Exception as e: except Exception as e:
tb = traceback.format_exc() tb = traceback.format_exc()
err_str = str(e).lower()
is_no_stream_url = ( is_no_stream_url = (
isinstance(e, RuntimeError) and str(e) == "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: if attempt == 1 or attempt == MAX_RETRIES:
msg = f"Track {track_id} attempt {attempt} failed: {e}\n{tb}" msg = f"Track {track_id} attempt {attempt} failed: {e}\n{tb}"
send_log_to_discord(msg, "ERROR", target) send_log_to_discord(msg, "ERROR", target)
@@ -575,8 +707,22 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
except Exception: except Exception:
pass 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) per_track_meta.append(track_info)
finally:
try:
await session.close()
except Exception:
pass
if not all_final_files: if not all_final_files:
if job: if job:
job.meta["tarball"] = None job.meta["tarball"] = None
@@ -624,7 +770,7 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
counter += 1 counter += 1
staged_tarball = staging_root / f"{base_name} ({counter}).tar.gz" 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) final_dir.mkdir(parents=True, exist_ok=True)
# Ensure we don't overwrite an existing final tarball. Preserve `.tar.gz` style. # 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) 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) os.remove(f)
except Exception: except Exception:
pass 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(): if not staged_tarball.exists():
send_log_to_discord( send_log_to_discord(
@@ -711,6 +865,9 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
color=0x00FF00, 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)] return [str(final_tarball)]
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()

View File

@@ -1081,6 +1081,27 @@ class SRUtil:
return combined_metadata return combined_metadata
except Exception as e: 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 # Exponential backoff with jitter for 429 or other errors
delay = self.RETRY_DELAY * (2 ** (attempt - 1)) + random.uniform(0, 0.5) delay = self.RETRY_DELAY * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
if attempt < self.MAX_METADATA_RETRIES: if attempt < self.MAX_METADATA_RETRIES:
@@ -1179,6 +1200,47 @@ class SRUtil:
if not tracks: if not tracks:
return None 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 provided, select the track with closest duration match
if duration is not None: if duration is not None:
tracks_with_diff = [ tracks_with_diff = [
@@ -1195,7 +1257,88 @@ class SRUtil:
best_track = tracks[0] best_track = tracks[0]
track_id = best_track.get("id") 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: if not track_id:
return None return None