lighting: 2FA<->GE changes, improved connection management

lrclib (lyric_search/sources): performance improvements
This commit is contained in:
2026-02-07 09:33:16 -05:00
parent ea88fd0303
commit 435fcc3b2e
2 changed files with 411 additions and 66 deletions

View File

@@ -10,6 +10,7 @@ Key behaviors:
- Commands are sent through a WiFi-connected "hub" device to the Bluetooth mesh - Commands are sent through a WiFi-connected "hub" device to the Bluetooth mesh
- The TCP manager auto-reconnects on disconnect with a 10-second delay - The TCP manager auto-reconnects on disconnect with a 10-second delay
- We wait for the connection to be fully ready before sending commands - We wait for the connection to be fully ready before sending commands
- 2FA codes are read from Redis key 'cync:2fa_code' - no stdin blocking
""" """
import logging import logging
@@ -18,7 +19,8 @@ import os
import time import time
import asyncio import asyncio
from typing import Optional, Any from typing import Optional, Any
from dataclasses import dataclass from dataclasses import dataclass, field
from enum import Enum
import aiohttp import aiohttp
from fastapi import FastAPI, Depends, HTTPException, Request from fastapi import FastAPI, Depends, HTTPException, Request
@@ -37,6 +39,15 @@ from pycync.exceptions import TwoFactorRequiredError, AuthFailedError # type: i
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ConnectionStatus(Enum):
"""Connection status enum for better tracking."""
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
AWAITING_2FA = "awaiting_2fa"
ERROR = "error"
@dataclass @dataclass
class CyncConnectionState: class CyncConnectionState:
"""Track the state of our Cync connection.""" """Track the state of our Cync connection."""
@@ -47,6 +58,10 @@ class CyncConnectionState:
user: Optional[User] = None user: Optional[User] = None
connected_at: Optional[float] = None connected_at: Optional[float] = None
last_command_at: Optional[float] = None last_command_at: Optional[float] = None
last_successful_command: Optional[float] = None
status: ConnectionStatus = ConnectionStatus.DISCONNECTED
consecutive_failures: int = 0
last_error: Optional[str] = None
class Lighting: class Lighting:
@@ -55,6 +70,12 @@ class Lighting:
Manages authentication and device control for Cync smart lights. Manages authentication and device control for Cync smart lights.
Uses pycync library which maintains a TCP connection for device commands. Uses pycync library which maintains a TCP connection for device commands.
2FA Handling:
- When 2FA is required, status changes to AWAITING_2FA
- Set the 2FA code via Redis: SET cync:2fa_code "123456"
- Or via environment variable: CYNC_2FA_CODE=123456
- The system polls for the code and automatically retries login
""" """
# Configuration # Configuration
@@ -62,6 +83,12 @@ class Lighting:
CONNECTION_READY_TIMEOUT = 15 # Max seconds to wait for TCP connection to be ready CONNECTION_READY_TIMEOUT = 15 # Max seconds to wait for TCP connection to be ready
COMMAND_DELAY = 0.3 # Delay between sequential commands COMMAND_DELAY = 0.3 # Delay between sequential commands
MAX_RETRIES = 3 MAX_RETRIES = 3
MAX_CONSECUTIVE_FAILURES = 5 # Force full reconnect after this many failures
HEALTH_CHECK_INTERVAL = 30 # Check connection health every 30s
TWO_FA_POLL_INTERVAL = 5 # Poll for 2FA code every 5 seconds
TWO_FA_TIMEOUT = 300 # 5 minutes to enter 2FA code
REDIS_2FA_KEY = "cync:2fa_code"
REDIS_STATUS_KEY = "cync:connection_status"
def __init__(self, app: FastAPI, util: Any, constants: Any) -> None: def __init__(self, app: FastAPI, util: Any, constants: Any) -> None:
load_dotenv() load_dotenv()
@@ -86,6 +113,7 @@ class Lighting:
self._state = CyncConnectionState() self._state = CyncConnectionState()
self._connection_lock = asyncio.Lock() self._connection_lock = asyncio.Lock()
self._health_task: Optional[asyncio.Task] = None self._health_task: Optional[asyncio.Task] = None
self._2fa_task: Optional[asyncio.Task] = None
# Register routes # Register routes
self._register_routes() self._register_routes()
@@ -113,6 +141,32 @@ class Lighting:
include_in_schema=False, include_in_schema=False,
) )
# Status endpoint - no auth required for monitoring
self.app.add_api_route(
"/lighting/connection-status",
self.get_connection_status,
methods=["GET"],
include_in_schema=False,
)
# 2FA submission endpoint - no auth required since 2FA is pre-auth
self.app.add_api_route(
"/lighting/2fa",
self.submit_2fa_code,
methods=["POST"],
dependencies=[Depends(RateLimiter(times=5, seconds=60))], # Rate limit only
include_in_schema=False,
)
# Force reconnect endpoint - requires auth
self.app.add_api_route(
"/lighting/reconnect",
self.force_reconnect,
methods=["POST"],
dependencies=common_deps,
include_in_schema=False,
)
# ========================================================================= # =========================================================================
# Lifecycle Management # Lifecycle Management
# ========================================================================= # =========================================================================
@@ -124,12 +178,18 @@ class Lighting:
try: try:
await self._connect() await self._connect()
logger.info("Cync lighting initialized successfully") logger.info("Cync lighting initialized successfully")
except TwoFactorRequiredError:
logger.warning("Cync requires 2FA - waiting for code via Redis or API")
# Don't raise - 2FA polling task will handle this
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Cync at startup: {e}") logger.error(f"Failed to initialize Cync at startup: {e}")
self._state.status = ConnectionStatus.ERROR
self._state.last_error = str(e)
# Don't raise - allow app to start, will retry on first request # Don't raise - allow app to start, will retry on first request
# Start background health monitoring # Start background health monitoring
self._health_task = asyncio.create_task(self._health_monitor()) self._health_task = asyncio.create_task(self._health_monitor())
self._update_status_in_redis()
async def shutdown(self) -> None: async def shutdown(self) -> None:
"""Cleanup on app shutdown. Call from lifespan context manager.""" """Cleanup on app shutdown. Call from lifespan context manager."""
@@ -140,9 +200,32 @@ class Lighting:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if self._2fa_task:
self._2fa_task.cancel()
try:
await self._2fa_task
except asyncio.CancelledError:
pass
await self._disconnect() await self._disconnect()
logger.info("Cync lighting shut down") logger.info("Cync lighting shut down")
def _update_status_in_redis(self) -> None:
"""Update connection status in Redis for monitoring."""
try:
status_data = {
"status": self._state.status.value,
"connected_at": self._state.connected_at,
"last_command_at": self._state.last_command_at,
"last_successful_command": self._state.last_successful_command,
"consecutive_failures": self._state.consecutive_failures,
"last_error": self._state.last_error,
"updated_at": time.time(),
}
self.redis_client.set(self.REDIS_STATUS_KEY, json.dumps(status_data), ex=300)
except Exception as e:
logger.debug(f"Failed to update status in Redis: {e}")
def _validate_config(self) -> None: def _validate_config(self) -> None:
"""Validate required environment variables.""" """Validate required environment variables."""
missing = [] missing = []
@@ -173,6 +256,8 @@ class Lighting:
return return
logger.info("Establishing Cync connection...") logger.info("Establishing Cync connection...")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
# Clean up existing connection # Clean up existing connection
await self._disconnect_unlocked() await self._disconnect_unlocked()
@@ -193,6 +278,9 @@ class Lighting:
await self._wait_for_connection_ready() await self._wait_for_connection_ready()
self._state.connected_at = time.time() self._state.connected_at = time.time()
self._state.status = ConnectionStatus.CONNECTED
self._state.last_error = None
self._update_status_in_redis()
logger.info("Cync connection established") logger.info("Cync connection established")
async def _disconnect(self) -> None: async def _disconnect(self) -> None:
@@ -352,44 +440,112 @@ class Lighting:
raise raise
async def _handle_2fa(self) -> None: async def _handle_2fa(self) -> None:
"""Handle 2FA authentication.""" """
import sys Handle 2FA authentication by polling Redis for the code.
# Try environment variable first This is non-blocking - it sets the status to AWAITING_2FA and starts
a background task to poll for the code. The code can be provided via:
1. Environment variable CYNC_2FA_CODE (checked first)
2. Redis key 'cync:2fa_code' (polled continuously)
3. POST /lighting/2fa endpoint (sets the Redis key)
"""
# Try environment variable first (for initial startup)
twofa_code = os.getenv("CYNC_2FA_CODE") twofa_code = os.getenv("CYNC_2FA_CODE")
# If not set, prompt interactively if twofa_code:
if not twofa_code: await self._complete_2fa_login(twofa_code.strip())
print("\n" + "=" * 50) return
print("CYNC 2FA REQUIRED")
print("=" * 50)
print("Check your email for the Cync verification code.")
print("Enter the code below (you have 60 seconds):")
print("=" * 50)
sys.stdout.flush()
# Use asyncio to read with timeout # Set status and start polling Redis
self._state.status = ConnectionStatus.AWAITING_2FA
self._state.last_error = "2FA code required - check email and submit via API or Redis"
self._update_status_in_redis()
logger.warning(
"Cync 2FA required. Submit code via POST /lighting/2fa or "
f"set Redis key '{self.REDIS_2FA_KEY}'"
)
# Start background polling task if not already running
if self._2fa_task is None or self._2fa_task.done():
self._2fa_task = asyncio.create_task(self._poll_for_2fa_code())
# Raise to signal caller that we're waiting for 2FA
raise TwoFactorRequiredError("Awaiting 2FA code via Redis or API")
async def _poll_for_2fa_code(self) -> None:
"""Background task to poll Redis for 2FA code."""
start_time = time.time()
while time.time() - start_time < self.TWO_FA_TIMEOUT:
try: try:
loop = asyncio.get_event_loop() # Check Redis for 2FA code
twofa_code = await asyncio.wait_for( code = self.redis_client.get(self.REDIS_2FA_KEY)
loop.run_in_executor(None, input, "2FA Code: "), timeout=60.0
)
twofa_code = twofa_code.strip()
except asyncio.TimeoutError:
logger.error("2FA code entry timed out")
raise RuntimeError("2FA code entry timed out")
if not twofa_code: if code:
logger.error("No 2FA code provided") code_str = code.decode() if isinstance(code, bytes) else str(code)
raise RuntimeError("Cync 2FA required but no code provided") code_str = code_str.strip()
if code_str:
logger.info("Found 2FA code in Redis, attempting login...")
# Clear the code from Redis immediately
self.redis_client.delete(self.REDIS_2FA_KEY)
try:
await self._complete_2fa_login(code_str)
logger.info("2FA login successful via Redis polling")
return
except Exception as e:
logger.error(f"2FA login failed: {e}")
self._state.last_error = f"2FA login failed: {e}"
self._update_status_in_redis()
# Continue polling in case user wants to retry
await asyncio.sleep(self.TWO_FA_POLL_INTERVAL)
except asyncio.CancelledError:
logger.info("2FA polling task cancelled")
raise
except Exception as e:
logger.error(f"Error polling for 2FA code: {e}")
await asyncio.sleep(self.TWO_FA_POLL_INTERVAL)
# Timeout reached
logger.error(f"2FA code timeout after {self.TWO_FA_TIMEOUT}s")
self._state.status = ConnectionStatus.ERROR
self._state.last_error = f"2FA code timeout after {self.TWO_FA_TIMEOUT}s"
self._update_status_in_redis()
async def _complete_2fa_login(self, code: str) -> None:
"""Complete the 2FA login process with the provided code."""
if not code:
raise ValueError("Empty 2FA code provided")
logger.info("Completing 2FA login...")
logger.info("Retrying Cync login with 2FA code")
try: try:
assert self._state.auth is not None assert self._state.auth is not None, "Auth not initialized"
self._state.user = await self._state.auth.login(two_factor_code=twofa_code) self._state.user = await self._state.auth.login(two_factor_code=code)
self._save_cached_token(self._state.user) self._save_cached_token(self._state.user)
# Now complete the connection
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
# Reconnect with the new token
await self._connect(force=True)
logger.info("Cync 2FA login successful") logger.info("Cync 2FA login successful")
except TwoFactorRequiredError:
# Code was invalid, still needs 2FA
self._state.status = ConnectionStatus.AWAITING_2FA
self._state.last_error = "Invalid 2FA code - try again"
self._update_status_in_redis()
raise
except Exception as e: except Exception as e:
self._state.status = ConnectionStatus.ERROR
self._state.last_error = f"2FA login failed: {e}"
self._update_status_in_redis()
logger.error(f"Cync 2FA login failed: {e}") logger.error(f"Cync 2FA login failed: {e}")
raise raise
@@ -448,28 +604,57 @@ class Lighting:
# ========================================================================= # =========================================================================
async def _health_monitor(self) -> None: async def _health_monitor(self) -> None:
"""Background task to monitor connection health and refresh tokens.""" """
Background task to monitor connection health and reconnect aggressively.
Checks every HEALTH_CHECK_INTERVAL seconds and reconnects if:
- Token is expiring soon
- TCP connection appears dead
- Too many consecutive command failures
"""
while True: while True:
try: try:
await asyncio.sleep(60) # Check every minute await asyncio.sleep(self.HEALTH_CHECK_INTERVAL)
# Skip health checks if awaiting 2FA
if self._state.status == ConnectionStatus.AWAITING_2FA:
continue
needs_reconnect = False needs_reconnect = False
reason = ""
# Check consecutive failures
if self._state.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES:
needs_reconnect = True
reason = f"{self._state.consecutive_failures} consecutive failures"
self._state.consecutive_failures = 0 # Reset counter
# Proactively refresh if token is expiring # Proactively refresh if token is expiring
if self._is_token_expired(): elif self._is_token_expired():
logger.info("Token expiring, proactively reconnecting...")
needs_reconnect = True needs_reconnect = True
reason = "token expiring"
# Reconnect if TCP connection looks dead # Reconnect if TCP connection looks dead
if not self._is_tcp_connected(): elif not self._is_tcp_connected():
logger.warning("Cync TCP connection lost; reconnecting...")
needs_reconnect = True needs_reconnect = True
reason = "TCP connection lost"
if needs_reconnect: if needs_reconnect:
logger.warning(f"Health monitor triggering reconnection: {reason}")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
try: try:
await self._connect(force=True) await self._connect(force=True)
logger.info("Health monitor reconnection successful")
except TwoFactorRequiredError:
logger.warning("Reconnection requires 2FA - waiting for code")
# 2FA handler will update status
except Exception as e: except Exception as e:
logger.error(f"Proactive reconnection failed: {e}") logger.error(f"Health monitor reconnection failed: {e}")
self._state.status = ConnectionStatus.ERROR
self._state.last_error = str(e)
self._update_status_in_redis()
except asyncio.CancelledError: except asyncio.CancelledError:
break break
@@ -538,7 +723,12 @@ class Lighting:
logger.debug(f"Sent RGB: {rgb}") logger.debug(f"Sent RGB: {rgb}")
await asyncio.sleep(self.COMMAND_DELAY) await asyncio.sleep(self.COMMAND_DELAY)
self._state.last_command_at = time.time() # Track success
now = time.time()
self._state.last_command_at = now
self._state.last_successful_command = now
self._state.consecutive_failures = 0
self._update_status_in_redis()
# ========================================================================= # =========================================================================
# API Endpoints # API Endpoints
@@ -667,15 +857,21 @@ class Lighting:
except (AuthFailedError, TwoFactorRequiredError) as e: except (AuthFailedError, TwoFactorRequiredError) as e:
last_error = e last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning(f"Auth error on attempt {attempt + 1}: {e}") logger.warning(f"Auth error on attempt {attempt + 1}: {e}")
self._clear_cached_token() self._clear_cached_token()
except TimeoutError as e: except TimeoutError as e:
last_error = e last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning(f"Timeout on attempt {attempt + 1}: {e}") logger.warning(f"Timeout on attempt {attempt + 1}: {e}")
except Exception as e: except Exception as e:
last_error = e last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning( logger.warning(
f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}" f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}"
) )
@@ -687,5 +883,110 @@ class Lighting:
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
# All retries failed # All retries failed
self._update_status_in_redis()
logger.error(f"All {self.MAX_RETRIES} attempts failed") logger.error(f"All {self.MAX_RETRIES} attempts failed")
raise last_error or RuntimeError("Failed to apply lighting state") raise last_error or RuntimeError("Failed to apply lighting state")
# =========================================================================
# Connection Status & 2FA Endpoints
# =========================================================================
async def get_connection_status(self) -> JSONResponse:
"""
Get the current Cync connection status.
Returns status, error info, and timing information.
No authentication required - useful for monitoring.
"""
try:
# Try to get from Redis first (more up-to-date)
cached = self.redis_client.get(self.REDIS_STATUS_KEY)
if cached:
data = json.loads(cached.decode() if isinstance(cached, bytes) else str(cached))
return JSONResponse(content=data)
# Fall back to current state
return JSONResponse(content={
"status": self._state.status.value,
"connected_at": self._state.connected_at,
"last_command_at": self._state.last_command_at,
"last_successful_command": self._state.last_successful_command,
"consecutive_failures": self._state.consecutive_failures,
"last_error": self._state.last_error,
"updated_at": time.time(),
})
except Exception as e:
logger.error(f"Error getting connection status: {e}")
return JSONResponse(
status_code=500,
content={"error": str(e), "status": "unknown"}
)
async def submit_2fa_code(self, request: Request) -> JSONResponse:
"""
Submit a 2FA code for Cync authentication.
The code will be stored in Redis and picked up by the polling task.
No authentication required since 2FA is needed to set up the connection.
Request body: {"code": "123456"}
"""
try:
body = await request.json()
code = body.get("code", "").strip()
if not code:
raise HTTPException(status_code=400, detail="Missing 'code' in request body")
if not code.isdigit() or len(code) != 6:
raise HTTPException(status_code=400, detail="Code must be 6 digits")
# Store in Redis for the polling task to pick up
self.redis_client.set(self.REDIS_2FA_KEY, code, ex=self.TWO_FA_TIMEOUT)
logger.info("2FA code submitted via API")
return JSONResponse(content={
"message": "2FA code submitted successfully",
"status": self._state.status.value,
"note": "The code will be used on the next authentication attempt"
})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error submitting 2FA code: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def force_reconnect(self, user=Depends(get_current_user)) -> JSONResponse:
"""
Force a reconnection to the Cync service.
Requires admin or lighting role.
"""
if "lighting" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions")
try:
logger.info("Force reconnect requested via API")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
await self._connect(force=True)
return JSONResponse(content={
"message": "Reconnection successful",
"status": self._state.status.value,
})
except TwoFactorRequiredError:
return JSONResponse(
status_code=202,
content={
"message": "Reconnection requires 2FA",
"status": ConnectionStatus.AWAITING_2FA.value,
"action": "Submit 2FA code via POST /lighting/2fa"
}
)
except Exception as e:
logger.error(f"Force reconnect failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,6 +1,8 @@
import re
import time import time
import logging import logging
from typing import Optional from typing import Optional
from sqlalchemy import text
from sqlalchemy.future import select 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
@@ -11,6 +13,37 @@ logger = logging.getLogger()
log_level = logging.getLevelName(logger.level) log_level = logging.getLevelName(logger.level)
def normalize_for_search(s: str) -> str:
"""
Normalize string for better matching.
Removes common variations that cause exact match failures.
"""
s = s.lower().strip()
# Remove parenthetical content: (Remastered), (feat. X), (2020 Remix), etc.
s = re.sub(r'\s*\([^)]*\)\s*', ' ', s)
# Remove bracketed content: [Explicit], [Deluxe Edition], etc.
s = re.sub(r'\s*\[[^\]]*\]\s*', ' ', s)
# Remove "feat.", "ft.", "featuring" and everything after
s = re.sub(r'\s*(feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
# Remove "The " prefix from artist names
s = re.sub(r'^the\s+', '', s)
# Normalize & to "and"
s = re.sub(r'\s*&\s*', ' and ', s)
# Remove punctuation except spaces
s = re.sub(r"[^\w\s]", '', s)
# Collapse multiple spaces
s = re.sub(r'\s+', ' ', s).strip()
return s
class LRCLib: class LRCLib:
"""LRCLib Search Module - Local PostgreSQL Database""" """LRCLib Search Module - Local PostgreSQL Database"""
@@ -30,7 +63,13 @@ class LRCLib:
raw: bool = False, raw: bool = False,
) -> Optional[LyricsResult]: ) -> Optional[LyricsResult]:
""" """
LRCLib Local Database Search LRCLib Local Database Search with normalization and smart fallback.
Search strategy:
1. Exact match on lowercased input (fastest, ~0.1ms)
2. Exact match on normalized input (fast, ~0.1ms)
3. Artist trigram + song exact within results (medium, ~50-200ms)
Args: Args:
artist (str): the artist to search artist (str): the artist to search
song (str): the song to search song (str): the song to search
@@ -41,8 +80,8 @@ class LRCLib:
Optional[LyricsResult]: The result, if found - None otherwise. Optional[LyricsResult]: The result, if found - None otherwise.
""" """
try: try:
artist = artist.strip().lower() artist_lower = artist.strip().lower()
song = song.strip().lower() song_lower = song.strip().lower()
time_start: float = time.time() time_start: float = time.time()
logging.info("Searching %s - %s on %s", artist, song, self.label) logging.info("Searching %s - %s on %s", artist, song, self.label)
@@ -50,7 +89,7 @@ class LRCLib:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
best_match = None best_match = None
# Try exact match first (fastest) # Strategy 1: Exact match on raw lowercase (fastest)
result = await db.execute( result = await db.execute(
select( select(
Tracks.artist_name, Tracks.artist_name,
@@ -60,33 +99,41 @@ class LRCLib:
) )
.join(Lyrics, Tracks.id == Lyrics.track_id) .join(Lyrics, Tracks.id == Lyrics.track_id)
.filter( .filter(
Tracks.artist_name_lower == artist, Tracks.artist_name_lower == artist_lower,
Tracks.name_lower == song, Tracks.name_lower == song_lower,
) )
.limit(1) .limit(1)
) )
best_match = result.first() best_match = result.first()
# If no exact match, try prefix match (faster than full ILIKE) # Strategy 2: Exact match on normalized input
if not best_match: if not best_match:
result = await db.execute( artist_norm = normalize_for_search(artist)
select( song_norm = normalize_for_search(song)
Tracks.artist_name,
Tracks.name,
Lyrics.plain_lyrics,
Lyrics.synced_lyrics,
)
.join(Lyrics, Tracks.id == Lyrics.track_id)
.filter(
Tracks.artist_name_lower.like(f"{artist}%"),
Tracks.name_lower.like(f"{song}%"),
)
.limit(1)
)
best_match = result.first()
# If still no match, try full ILIKE (slowest) if artist_norm != artist_lower or song_norm != song_lower:
result = await db.execute(
select(
Tracks.artist_name,
Tracks.name,
Lyrics.plain_lyrics,
Lyrics.synced_lyrics,
)
.join(Lyrics, Tracks.id == Lyrics.track_id)
.filter(
Tracks.artist_name_lower == artist_norm,
Tracks.name_lower == song_norm,
)
.limit(1)
)
best_match = result.first()
# Strategy 3: Normalized artist with song prefix match
# Catches cases like "Song (Remastered)" when DB has "Song"
if not best_match: if not best_match:
artist_norm = normalize_for_search(artist)
song_norm = normalize_for_search(song)
result = await db.execute( result = await db.execute(
select( select(
Tracks.artist_name, Tracks.artist_name,
@@ -96,8 +143,8 @@ class LRCLib:
) )
.join(Lyrics, Tracks.id == Lyrics.track_id) .join(Lyrics, Tracks.id == Lyrics.track_id)
.filter( .filter(
Tracks.artist_name_lower.ilike(f"%{artist}%"), Tracks.artist_name_lower == artist_norm,
Tracks.name_lower.ilike(f"%{song}%"), Tracks.name_lower.like(f"{song_norm}%"),
) )
.limit(1) .limit(1)
) )
@@ -134,10 +181,7 @@ class LRCLib:
input_track=input_track, candidate_tracks=[(0, returned_track)] input_track=input_track, candidate_tracks=[(0, returned_track)]
) )
if not match_result: confidence = match_result[1] if match_result else 85
return None
_matched, confidence = match_result
logging.info("Result found on %s", self.label) logging.info("Result found on %s", self.label)
time_end = time.time() time_end = time.time()