lighting: 2FA<->GE changes, improved connection management
lrclib (lyric_search/sources): performance improvements
This commit is contained in:
@@ -10,6 +10,7 @@ Key behaviors:
|
||||
- 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
|
||||
- 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
|
||||
@@ -18,7 +19,8 @@ import os
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Optional, Any
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
import aiohttp
|
||||
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||
@@ -37,6 +39,15 @@ from pycync.exceptions import TwoFactorRequiredError, AuthFailedError # type: i
|
||||
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
|
||||
class CyncConnectionState:
|
||||
"""Track the state of our Cync connection."""
|
||||
@@ -47,6 +58,10 @@ class CyncConnectionState:
|
||||
user: Optional[User] = None
|
||||
connected_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:
|
||||
@@ -55,6 +70,12 @@ class Lighting:
|
||||
|
||||
Manages authentication and device control for Cync smart lights.
|
||||
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
|
||||
@@ -62,6 +83,12 @@ class Lighting:
|
||||
CONNECTION_READY_TIMEOUT = 15 # Max seconds to wait for TCP connection to be ready
|
||||
COMMAND_DELAY = 0.3 # Delay between sequential commands
|
||||
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:
|
||||
load_dotenv()
|
||||
@@ -86,6 +113,7 @@ class Lighting:
|
||||
self._state = CyncConnectionState()
|
||||
self._connection_lock = asyncio.Lock()
|
||||
self._health_task: Optional[asyncio.Task] = None
|
||||
self._2fa_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Register routes
|
||||
self._register_routes()
|
||||
@@ -113,6 +141,32 @@ class Lighting:
|
||||
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
|
||||
# =========================================================================
|
||||
@@ -124,12 +178,18 @@ class Lighting:
|
||||
try:
|
||||
await self._connect()
|
||||
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:
|
||||
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
|
||||
|
||||
# Start background health monitoring
|
||||
self._health_task = asyncio.create_task(self._health_monitor())
|
||||
self._update_status_in_redis()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Cleanup on app shutdown. Call from lifespan context manager."""
|
||||
@@ -140,9 +200,32 @@ class Lighting:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._2fa_task:
|
||||
self._2fa_task.cancel()
|
||||
try:
|
||||
await self._2fa_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._disconnect()
|
||||
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:
|
||||
"""Validate required environment variables."""
|
||||
missing = []
|
||||
@@ -173,6 +256,8 @@ class Lighting:
|
||||
return
|
||||
|
||||
logger.info("Establishing Cync connection...")
|
||||
self._state.status = ConnectionStatus.CONNECTING
|
||||
self._update_status_in_redis()
|
||||
|
||||
# Clean up existing connection
|
||||
await self._disconnect_unlocked()
|
||||
@@ -193,6 +278,9 @@ class Lighting:
|
||||
await self._wait_for_connection_ready()
|
||||
|
||||
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")
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
@@ -352,44 +440,112 @@ class Lighting:
|
||||
raise
|
||||
|
||||
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")
|
||||
|
||||
# If not set, prompt interactively
|
||||
if not twofa_code:
|
||||
print("\n" + "=" * 50)
|
||||
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()
|
||||
if twofa_code:
|
||||
await self._complete_2fa_login(twofa_code.strip())
|
||||
return
|
||||
|
||||
# Use asyncio to read with timeout
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
twofa_code = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, input, "2FA Code: "), timeout=60.0
|
||||
# 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}'"
|
||||
)
|
||||
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:
|
||||
logger.error("No 2FA code provided")
|
||||
raise RuntimeError("Cync 2FA required but no code provided")
|
||||
# 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())
|
||||
|
||||
logger.info("Retrying Cync login with 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:
|
||||
assert self._state.auth is not None
|
||||
self._state.user = await self._state.auth.login(two_factor_code=twofa_code)
|
||||
self._save_cached_token(self._state.user)
|
||||
logger.info("Cync 2FA login successful")
|
||||
# Check Redis for 2FA code
|
||||
code = self.redis_client.get(self.REDIS_2FA_KEY)
|
||||
|
||||
if code:
|
||||
code_str = code.decode() if isinstance(code, bytes) else str(code)
|
||||
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...")
|
||||
|
||||
try:
|
||||
assert self._state.auth is not None, "Auth not initialized"
|
||||
self._state.user = await self._state.auth.login(two_factor_code=code)
|
||||
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")
|
||||
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:
|
||||
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}")
|
||||
raise
|
||||
|
||||
@@ -448,28 +604,57 @@ class Lighting:
|
||||
# =========================================================================
|
||||
|
||||
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:
|
||||
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
|
||||
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
|
||||
if self._is_token_expired():
|
||||
logger.info("Token expiring, proactively reconnecting...")
|
||||
elif self._is_token_expired():
|
||||
needs_reconnect = True
|
||||
reason = "token expiring"
|
||||
|
||||
# Reconnect if TCP connection looks dead
|
||||
if not self._is_tcp_connected():
|
||||
logger.warning("Cync TCP connection lost; reconnecting...")
|
||||
elif not self._is_tcp_connected():
|
||||
needs_reconnect = True
|
||||
reason = "TCP connection lost"
|
||||
|
||||
if needs_reconnect:
|
||||
logger.warning(f"Health monitor triggering reconnection: {reason}")
|
||||
self._state.status = ConnectionStatus.CONNECTING
|
||||
self._update_status_in_redis()
|
||||
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
break
|
||||
@@ -538,7 +723,12 @@ class Lighting:
|
||||
logger.debug(f"Sent RGB: {rgb}")
|
||||
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
|
||||
@@ -667,15 +857,21 @@ class Lighting:
|
||||
|
||||
except (AuthFailedError, TwoFactorRequiredError) as 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}")
|
||||
self._clear_cached_token()
|
||||
|
||||
except TimeoutError as e:
|
||||
last_error = e
|
||||
self._state.consecutive_failures += 1
|
||||
self._state.last_error = str(e)
|
||||
logger.warning(f"Timeout on attempt {attempt + 1}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
self._state.consecutive_failures += 1
|
||||
self._state.last_error = str(e)
|
||||
logger.warning(
|
||||
f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}"
|
||||
)
|
||||
@@ -687,5 +883,110 @@ class Lighting:
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# All retries failed
|
||||
self._update_status_in_redis()
|
||||
logger.error(f"All {self.MAX_RETRIES} attempts failed")
|
||||
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))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.future import select
|
||||
from lyric_search import utils
|
||||
from lyric_search.constructors import LyricsResult
|
||||
@@ -11,6 +13,37 @@ logger = logging.getLogger()
|
||||
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:
|
||||
"""LRCLib Search Module - Local PostgreSQL Database"""
|
||||
|
||||
@@ -30,7 +63,13 @@ class LRCLib:
|
||||
raw: bool = False,
|
||||
) -> 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:
|
||||
artist (str): the artist to search
|
||||
song (str): the song to search
|
||||
@@ -41,8 +80,8 @@ class LRCLib:
|
||||
Optional[LyricsResult]: The result, if found - None otherwise.
|
||||
"""
|
||||
try:
|
||||
artist = artist.strip().lower()
|
||||
song = song.strip().lower()
|
||||
artist_lower = artist.strip().lower()
|
||||
song_lower = song.strip().lower()
|
||||
time_start: float = time.time()
|
||||
|
||||
logging.info("Searching %s - %s on %s", artist, song, self.label)
|
||||
@@ -50,7 +89,7 @@ class LRCLib:
|
||||
async with AsyncSessionLocal() as db:
|
||||
best_match = None
|
||||
|
||||
# Try exact match first (fastest)
|
||||
# Strategy 1: Exact match on raw lowercase (fastest)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Tracks.artist_name,
|
||||
@@ -60,15 +99,19 @@ class LRCLib:
|
||||
)
|
||||
.join(Lyrics, Tracks.id == Lyrics.track_id)
|
||||
.filter(
|
||||
Tracks.artist_name_lower == artist,
|
||||
Tracks.name_lower == song,
|
||||
Tracks.artist_name_lower == artist_lower,
|
||||
Tracks.name_lower == song_lower,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
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:
|
||||
artist_norm = normalize_for_search(artist)
|
||||
song_norm = normalize_for_search(song)
|
||||
|
||||
if artist_norm != artist_lower or song_norm != song_lower:
|
||||
result = await db.execute(
|
||||
select(
|
||||
Tracks.artist_name,
|
||||
@@ -78,15 +121,19 @@ class LRCLib:
|
||||
)
|
||||
.join(Lyrics, Tracks.id == Lyrics.track_id)
|
||||
.filter(
|
||||
Tracks.artist_name_lower.like(f"{artist}%"),
|
||||
Tracks.name_lower.like(f"{song}%"),
|
||||
Tracks.artist_name_lower == artist_norm,
|
||||
Tracks.name_lower == song_norm,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
best_match = result.first()
|
||||
|
||||
# If still no match, try full ILIKE (slowest)
|
||||
# Strategy 3: Normalized artist with song prefix match
|
||||
# Catches cases like "Song (Remastered)" when DB has "Song"
|
||||
if not best_match:
|
||||
artist_norm = normalize_for_search(artist)
|
||||
song_norm = normalize_for_search(song)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
Tracks.artist_name,
|
||||
@@ -96,8 +143,8 @@ class LRCLib:
|
||||
)
|
||||
.join(Lyrics, Tracks.id == Lyrics.track_id)
|
||||
.filter(
|
||||
Tracks.artist_name_lower.ilike(f"%{artist}%"),
|
||||
Tracks.name_lower.ilike(f"%{song}%"),
|
||||
Tracks.artist_name_lower == artist_norm,
|
||||
Tracks.name_lower.like(f"{song_norm}%"),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
@@ -134,10 +181,7 @@ class LRCLib:
|
||||
input_track=input_track, candidate_tracks=[(0, returned_track)]
|
||||
)
|
||||
|
||||
if not match_result:
|
||||
return None
|
||||
|
||||
_matched, confidence = match_result
|
||||
confidence = match_result[1] if match_result else 85
|
||||
|
||||
logging.info("Result found on %s", self.label)
|
||||
time_end = time.time()
|
||||
|
||||
Reference in New Issue
Block a user