various/stale
This commit is contained in:
27
base.py
27
base.py
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user