rm test.conf

This commit is contained in:
2025-12-18 07:30:39 -05:00
parent 041de95698
commit bc8b407a91
12 changed files with 348 additions and 303 deletions

50
base.py
View File

@@ -34,27 +34,27 @@ async def lifespan(app: FastAPI):
# Startup # Startup
uvicorn_access_logger = logging.getLogger("uvicorn.access") uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.disabled = True uvicorn_access_logger.disabled = True
# Start Radio playlists # Start Radio playlists
if "radio" in _routes and hasattr(_routes["radio"], "on_start"): if "radio" in _routes and hasattr(_routes["radio"], "on_start"):
await _routes["radio"].on_start() await _routes["radio"].on_start()
# Start endpoint background tasks # Start endpoint background tasks
if "trip" in _routes and hasattr(_routes["trip"], "startup"): if "trip" in _routes and hasattr(_routes["trip"], "startup"):
await _routes["trip"].startup() await _routes["trip"].startup()
if "lighting" in _routes and hasattr(_routes["lighting"], "startup"): if "lighting" in _routes and hasattr(_routes["lighting"], "startup"):
await _routes["lighting"].startup() await _routes["lighting"].startup()
logger.info("Application startup complete") logger.info("Application startup complete")
yield yield
# Shutdown # Shutdown
if "lighting" in _routes and hasattr(_routes["lighting"], "shutdown"): if "lighting" in _routes and hasattr(_routes["lighting"], "shutdown"):
await _routes["lighting"].shutdown() await _routes["lighting"].shutdown()
if "trip" in _routes and hasattr(_routes["trip"], "shutdown"): if "trip" in _routes and hasattr(_routes["trip"], "shutdown"):
await _routes["trip"].shutdown() await _routes["trip"].shutdown()
logger.info("Application shutdown complete") logger.info("Application shutdown complete")
@@ -141,24 +141,26 @@ End Blacklisted Routes
Actionable Routes Actionable Routes
""" """
_routes.update({ _routes.update(
"randmsg": importlib.import_module("endpoints.rand_msg").RandMsg( {
app, util, constants "randmsg": importlib.import_module("endpoints.rand_msg").RandMsg(
), app, util, constants
"lyrics": importlib.import_module("endpoints.lyric_search").LyricSearch( ),
app, util, constants "lyrics": importlib.import_module("endpoints.lyric_search").LyricSearch(
), app, util, constants
"yt": importlib.import_module("endpoints.yt").YT(app, util, constants), ),
"radio": importlib.import_module("endpoints.radio").Radio( "yt": importlib.import_module("endpoints.yt").YT(app, util, constants),
app, util, constants, loop "radio": importlib.import_module("endpoints.radio").Radio(
), app, util, constants, loop
"meme": importlib.import_module("endpoints.meme").Meme(app, util, constants), ),
"trip": importlib.import_module("endpoints.rip").RIP(app, util, constants), "meme": importlib.import_module("endpoints.meme").Meme(app, util, constants),
"auth": importlib.import_module("endpoints.auth").Auth(app), "trip": importlib.import_module("endpoints.rip").RIP(app, util, constants),
"lighting": importlib.import_module("endpoints.lighting").Lighting( "auth": importlib.import_module("endpoints.auth").Auth(app),
app, util, constants "lighting": importlib.import_module("endpoints.lighting").Lighting(
), app, util, constants
}) ),
}
)
# Misc endpoint depends on radio endpoint instance # Misc endpoint depends on radio endpoint instance
radio_endpoint = _routes.get("radio") radio_endpoint = _routes.get("radio")

View File

@@ -42,6 +42,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class CyncConnectionState: class CyncConnectionState:
"""Track the state of our Cync connection.""" """Track the state of our Cync connection."""
session: Optional[aiohttp.ClientSession] = None session: Optional[aiohttp.ClientSession] = None
auth: Optional[Auth] = None auth: Optional[Auth] = None
cync_api: Optional[Cync] = None cync_api: Optional[Cync] = None
@@ -53,52 +54,51 @@ class CyncConnectionState:
class Lighting: class Lighting:
""" """
Cync Lighting Controller Cync Lighting Controller
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.
""" """
# Configuration # Configuration
TOKEN_EXPIRY_BUFFER = 300 # Consider token expired 5 min before actual expiry TOKEN_EXPIRY_BUFFER = 300 # Consider token expired 5 min before actual expiry
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
def __init__(self, app: FastAPI, util: Any, constants: Any) -> None: def __init__(self, app: FastAPI, util: Any, constants: Any) -> None:
load_dotenv() load_dotenv()
self.app = app self.app = app
self.util = util self.util = util
self.constants = constants self.constants = constants
# Redis for state persistence # Redis for state persistence
self.redis_client = redis.Redis( self.redis_client = redis.Redis(
password=private.REDIS_PW, password=private.REDIS_PW, decode_responses=True
decode_responses=True
) )
self.lighting_key = "lighting:state" self.lighting_key = "lighting:state"
# Cync configuration from environment # Cync configuration from environment
self.cync_email = os.getenv("CYNC_EMAIL") self.cync_email = os.getenv("CYNC_EMAIL")
self.cync_password = os.getenv("CYNC_PASSWORD") self.cync_password = os.getenv("CYNC_PASSWORD")
self.cync_device_name = os.getenv("CYNC_DEVICE_NAME") self.cync_device_name = os.getenv("CYNC_DEVICE_NAME")
self.token_cache_path = "cync_token.json" self.token_cache_path = "cync_token.json"
# Connection state # Connection state
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
# Register routes # Register routes
self._register_routes() self._register_routes()
def _register_routes(self) -> None: def _register_routes(self) -> None:
"""Register FastAPI routes.""" """Register FastAPI routes."""
common_deps = [ common_deps = [
Depends(RateLimiter(times=25, seconds=2)), Depends(RateLimiter(times=25, seconds=2)),
Depends(get_current_user), Depends(get_current_user),
] ]
self.app.add_api_route( self.app.add_api_route(
"/lighting/state", "/lighting/state",
self.get_lighting_state, self.get_lighting_state,
@@ -106,7 +106,7 @@ class Lighting:
dependencies=common_deps, dependencies=common_deps,
include_in_schema=False, include_in_schema=False,
) )
self.app.add_api_route( self.app.add_api_route(
"/lighting/state", "/lighting/state",
self.set_lighting_state, self.set_lighting_state,
@@ -114,25 +114,25 @@ class Lighting:
dependencies=common_deps, dependencies=common_deps,
include_in_schema=False, include_in_schema=False,
) )
# ========================================================================= # =========================================================================
# Lifecycle Management # Lifecycle Management
# ========================================================================= # =========================================================================
async def startup(self) -> None: async def startup(self) -> None:
"""Initialize on app startup. Call from lifespan context manager.""" """Initialize on app startup. Call from lifespan context manager."""
self._validate_config() self._validate_config()
try: try:
await self._connect() await self._connect()
logger.info("Cync lighting initialized successfully") logger.info("Cync lighting initialized successfully")
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}")
# 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())
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."""
if self._health_task: if self._health_task:
@@ -141,10 +141,10 @@ class Lighting:
await self._health_task await self._health_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
await self._disconnect() await self._disconnect()
logger.info("Cync lighting shut down") logger.info("Cync lighting shut down")
def _validate_config(self) -> None: def _validate_config(self) -> None:
"""Validate required environment variables.""" """Validate required environment variables."""
missing = [] missing = []
@@ -154,18 +154,18 @@ class Lighting:
missing.append("CYNC_PASSWORD") missing.append("CYNC_PASSWORD")
if not self.cync_device_name: if not self.cync_device_name:
missing.append("CYNC_DEVICE_NAME") missing.append("CYNC_DEVICE_NAME")
if missing: if missing:
raise RuntimeError(f"Missing required env vars: {', '.join(missing)}") raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")
# ========================================================================= # =========================================================================
# Connection Management # Connection Management
# ========================================================================= # =========================================================================
async def _connect(self, force: bool = False) -> None: async def _connect(self, force: bool = False) -> None:
""" """
Establish connection to Cync cloud. Establish connection to Cync cloud.
This creates the aiohttp session, authenticates, and initializes This creates the aiohttp session, authenticates, and initializes
the pycync API which starts its TCP connection. the pycync API which starts its TCP connection.
""" """
@@ -173,124 +173,124 @@ class Lighting:
# Check if we need to connect # Check if we need to connect
if not force and self._is_connection_valid(): if not force and self._is_connection_valid():
return return
logger.info("Establishing Cync connection...") logger.info("Establishing Cync connection...")
# Clean up existing connection # Clean up existing connection
await self._disconnect_unlocked() await self._disconnect_unlocked()
# Create HTTP session # Create HTTP session
timeout = aiohttp.ClientTimeout(total=30, connect=10) timeout = aiohttp.ClientTimeout(total=30, connect=10)
self._state.session = aiohttp.ClientSession(timeout=timeout) self._state.session = aiohttp.ClientSession(timeout=timeout)
# Authenticate # Authenticate
await self._authenticate() await self._authenticate()
# Create Cync API (starts TCP connection) # Create Cync API (starts TCP connection)
logger.info("Creating Cync API instance...") logger.info("Creating Cync API instance...")
assert self._state.auth is not None # Set by _authenticate assert self._state.auth is not None # Set by _authenticate
self._state.cync_api = await Cync.create(self._state.auth) self._state.cync_api = await Cync.create(self._state.auth)
# Wait for TCP connection to be ready # Wait for TCP connection to be ready
await self._wait_for_connection_ready() await self._wait_for_connection_ready()
self._state.connected_at = time.time() self._state.connected_at = time.time()
logger.info("Cync connection established") logger.info("Cync connection established")
async def _disconnect(self) -> None: async def _disconnect(self) -> None:
"""Disconnect and cleanup resources.""" """Disconnect and cleanup resources."""
async with self._connection_lock: async with self._connection_lock:
await self._disconnect_unlocked() await self._disconnect_unlocked()
async def _disconnect_unlocked(self) -> None: async def _disconnect_unlocked(self) -> None:
"""Disconnect without acquiring lock (internal use).""" """Disconnect without acquiring lock (internal use)."""
# Shutdown pycync TCP connection # Shutdown pycync TCP connection
if self._state.cync_api: if self._state.cync_api:
try: try:
# pycync's command client has a shut_down method # pycync's command client has a shut_down method
client = getattr(self._state.cync_api, '_command_client', None) client = getattr(self._state.cync_api, "_command_client", None)
if client: if client:
await client.shut_down() await client.shut_down()
except Exception as e: except Exception as e:
logger.warning(f"Error shutting down Cync client: {e}") logger.warning(f"Error shutting down Cync client: {e}")
# Close HTTP session # Close HTTP session
if self._state.session and not self._state.session.closed: if self._state.session and not self._state.session.closed:
await self._state.session.close() await self._state.session.close()
await asyncio.sleep(0.1) # Allow cleanup await asyncio.sleep(0.1) # Allow cleanup
# Reset state # Reset state
self._state = CyncConnectionState() self._state = CyncConnectionState()
def _is_connection_valid(self) -> bool: def _is_connection_valid(self) -> bool:
"""Check if current connection is usable.""" """Check if current connection is usable."""
if not self._state.cync_api or not self._state.session: if not self._state.cync_api or not self._state.session:
return False return False
if self._state.session.closed: if self._state.session.closed:
return False 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")
return False return False
return True 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:
return True return True
expires_at = getattr(self._state.user, 'expires_at', 0) expires_at = getattr(self._state.user, "expires_at", 0)
return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER) return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER)
async def _wait_for_connection_ready(self) -> None: async def _wait_for_connection_ready(self) -> None:
""" """
Wait for pycync TCP connection to be fully ready. Wait for pycync TCP connection to be fully ready.
pycync's TCP manager waits for login acknowledgment before sending pycync's TCP manager waits for login acknowledgment before sending
any commands. We need to wait for this to complete. any commands. We need to wait for this to complete.
""" """
if not self._state.cync_api: if not self._state.cync_api:
raise RuntimeError("Cync API not initialized") raise RuntimeError("Cync API not initialized")
client = getattr(self._state.cync_api, '_command_client', None) client = getattr(self._state.cync_api, "_command_client", None)
if not client: if not client:
logger.warning("Could not access command client") logger.warning("Could not access command client")
return return
tcp_manager = getattr(client, '_tcp_manager', None) tcp_manager = getattr(client, "_tcp_manager", None)
if not tcp_manager: if not tcp_manager:
logger.warning("Could not access TCP manager") logger.warning("Could not access TCP manager")
return return
# Wait for login to be acknowledged # Wait for login to be acknowledged
start = time.time() start = time.time()
while not getattr(tcp_manager, '_login_acknowledged', False): while not getattr(tcp_manager, "_login_acknowledged", False):
if time.time() - start > self.CONNECTION_READY_TIMEOUT: if time.time() - start > self.CONNECTION_READY_TIMEOUT:
raise TimeoutError("Timed out waiting for Cync login acknowledgment") raise TimeoutError("Timed out waiting for Cync login acknowledgment")
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
logger.debug("Waiting for Cync TCP login acknowledgment...") logger.debug("Waiting for Cync TCP login acknowledgment...")
# Give a tiny bit more time for device probing to start # Give a tiny bit more time for device probing to start
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
logger.info(f"Cync TCP connection ready (took {time.time() - start:.1f}s)") logger.info(f"Cync TCP connection ready (took {time.time() - start:.1f}s)")
# ========================================================================= # =========================================================================
# Authentication # Authentication
# ========================================================================= # =========================================================================
async def _authenticate(self) -> None: async def _authenticate(self) -> None:
"""Authenticate with Cync, using cached token if valid.""" """Authenticate with Cync, using cached token if valid."""
# Try cached token first # Try cached token first
cached_user = self._load_cached_token() cached_user = self._load_cached_token()
# These are validated by _validate_config at startup # These are validated by _validate_config at startup
assert self._state.session is not None assert self._state.session is not None
assert self.cync_email is not None assert self.cync_email is not None
assert self.cync_password is not None assert self.cync_password is not None
if cached_user and not self._is_user_token_expired(cached_user): if cached_user and not self._is_user_token_expired(cached_user):
logger.info("Using cached Cync token") logger.info("Using cached Cync token")
self._state.auth = Auth( self._state.auth = Auth(
@@ -301,7 +301,7 @@ class Lighting:
) )
self._state.user = cached_user self._state.user = cached_user
return return
# Need fresh login # Need fresh login
logger.info("Performing fresh Cync login...") logger.info("Performing fresh Cync login...")
self._state.auth = Auth( self._state.auth = Auth(
@@ -309,7 +309,7 @@ class Lighting:
username=self.cync_email, username=self.cync_email,
password=self.cync_password, password=self.cync_password,
) )
try: try:
self._state.user = await self._state.auth.login() self._state.user = await self._state.auth.login()
self._save_cached_token(self._state.user) self._save_cached_token(self._state.user)
@@ -319,14 +319,14 @@ class Lighting:
except AuthFailedError as e: except AuthFailedError as e:
logger.error(f"Cync authentication failed: {e}") logger.error(f"Cync authentication failed: {e}")
raise raise
async def _handle_2fa(self) -> None: async def _handle_2fa(self) -> None:
"""Handle 2FA authentication.""" """Handle 2FA authentication."""
import sys import sys
# Try environment variable first # Try environment variable first
twofa_code = os.getenv("CYNC_2FA_CODE") twofa_code = os.getenv("CYNC_2FA_CODE")
# If not set, prompt interactively # If not set, prompt interactively
if not twofa_code: if not twofa_code:
print("\n" + "=" * 50) print("\n" + "=" * 50)
@@ -336,23 +336,22 @@ class Lighting:
print("Enter the code below (you have 60 seconds):") print("Enter the code below (you have 60 seconds):")
print("=" * 50) print("=" * 50)
sys.stdout.flush() sys.stdout.flush()
# Use asyncio to read with timeout # Use asyncio to read with timeout
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
twofa_code = await asyncio.wait_for( twofa_code = await asyncio.wait_for(
loop.run_in_executor(None, input, "2FA Code: "), loop.run_in_executor(None, input, "2FA Code: "), timeout=60.0
timeout=60.0
) )
twofa_code = twofa_code.strip() twofa_code = twofa_code.strip()
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error("2FA code entry timed out") logger.error("2FA code entry timed out")
raise RuntimeError("2FA code entry timed out") raise RuntimeError("2FA code entry timed out")
if not twofa_code: if not twofa_code:
logger.error("No 2FA code provided") logger.error("No 2FA code provided")
raise RuntimeError("Cync 2FA required but no code provided") raise RuntimeError("Cync 2FA required but no code provided")
logger.info("Retrying Cync login with 2FA code") logger.info("Retrying Cync login with 2FA code")
try: try:
assert self._state.auth is not None assert self._state.auth is not None
@@ -362,48 +361,48 @@ class Lighting:
except Exception as e: except Exception as e:
logger.error(f"Cync 2FA login failed: {e}") logger.error(f"Cync 2FA login failed: {e}")
raise raise
def _is_user_token_expired(self, user: User) -> bool: def _is_user_token_expired(self, user: User) -> bool:
"""Check if a user's token is expired.""" """Check if a user's token is expired."""
expires_at = getattr(user, 'expires_at', 0) expires_at = getattr(user, "expires_at", 0)
return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER) return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER)
def _load_cached_token(self) -> Optional[User]: def _load_cached_token(self) -> Optional[User]:
"""Load cached authentication token from disk.""" """Load cached authentication token from disk."""
try: try:
if not os.path.exists(self.token_cache_path): if not os.path.exists(self.token_cache_path):
return None return None
with open(self.token_cache_path, 'r') as f: with open(self.token_cache_path, "r") as f:
data = json.load(f) data = json.load(f)
return User( return User(
access_token=data['access_token'], access_token=data["access_token"],
refresh_token=data['refresh_token'], refresh_token=data["refresh_token"],
authorize=data['authorize'], authorize=data["authorize"],
user_id=data['user_id'], user_id=data["user_id"],
expires_at=data['expires_at'], expires_at=data["expires_at"],
) )
except Exception as e: except Exception as e:
logger.warning(f"Failed to load cached token: {e}") logger.warning(f"Failed to load cached token: {e}")
return None return None
def _save_cached_token(self, user: User) -> None: def _save_cached_token(self, user: User) -> None:
"""Save authentication token to disk.""" """Save authentication token to disk."""
try: try:
data = { data = {
'access_token': user.access_token, "access_token": user.access_token,
'refresh_token': user.refresh_token, "refresh_token": user.refresh_token,
'authorize': user.authorize, "authorize": user.authorize,
'user_id': user.user_id, "user_id": user.user_id,
'expires_at': user.expires_at, "expires_at": user.expires_at,
} }
with open(self.token_cache_path, 'w') as f: with open(self.token_cache_path, "w") as f:
json.dump(data, f) json.dump(data, f)
logger.debug("Saved Cync token to disk") logger.debug("Saved Cync token to disk")
except Exception as e: except Exception as e:
logger.warning(f"Failed to save token: {e}") logger.warning(f"Failed to save token: {e}")
def _clear_cached_token(self) -> None: def _clear_cached_token(self) -> None:
"""Remove cached token file.""" """Remove cached token file."""
try: try:
@@ -412,17 +411,17 @@ class Lighting:
logger.info("Cleared cached token") logger.info("Cleared cached token")
except OSError: except OSError:
pass pass
# ========================================================================= # =========================================================================
# Health Monitoring # Health Monitoring
# ========================================================================= # =========================================================================
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 refresh tokens."""
while True: while True:
try: try:
await asyncio.sleep(300) # Check every 5 minutes await asyncio.sleep(300) # Check every 5 minutes
# 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...")
@@ -430,38 +429,38 @@ class Lighting:
await self._connect(force=True) await self._connect(force=True)
except Exception as e: except Exception as e:
logger.error(f"Proactive reconnection failed: {e}") logger.error(f"Proactive reconnection failed: {e}")
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"Health monitor error: {e}") logger.error(f"Health monitor error: {e}")
# ========================================================================= # =========================================================================
# Device Control # Device Control
# ========================================================================= # =========================================================================
async def _get_device(self): async def _get_device(self):
"""Get the target light device.""" """Get the target light device."""
if not self._state.cync_api: if not self._state.cync_api:
raise RuntimeError("Cync not connected") raise RuntimeError("Cync not connected")
devices = self._state.cync_api.get_devices() devices = self._state.cync_api.get_devices()
if not devices: if not devices:
raise RuntimeError("No devices found") raise RuntimeError("No devices found")
device = next( device = next(
(d for d in devices if getattr(d, 'name', None) == self.cync_device_name), (d for d in devices if getattr(d, "name", None) == self.cync_device_name),
None None,
) )
if not device: if not device:
available = [getattr(d, 'name', 'unnamed') for d in devices] available = [getattr(d, "name", "unnamed") for d in devices]
raise RuntimeError( raise RuntimeError(
f"Device '{self.cync_device_name}' not found. Available: {available}" f"Device '{self.cync_device_name}' not found. Available: {available}"
) )
return device return device
async def _send_commands( async def _send_commands(
self, self,
power: str, power: str,
@@ -470,13 +469,13 @@ class Lighting:
) -> None: ) -> None:
""" """
Send commands to the light device. Send commands to the light device.
Commands are sent sequentially with small delays to ensure Commands are sent sequentially with small delays to ensure
the TCP connection processes each one. the TCP connection processes each one.
""" """
device = await self._get_device() device = await self._get_device()
logger.info(f"Sending commands to device: {device.name}") logger.info(f"Sending commands to device: {device.name}")
# Power # Power
if power == "on": if power == "on":
await device.turn_on() await device.turn_on()
@@ -485,107 +484,128 @@ class Lighting:
await device.turn_off() await device.turn_off()
logger.debug("Sent turn_off") logger.debug("Sent turn_off")
await asyncio.sleep(self.COMMAND_DELAY) await asyncio.sleep(self.COMMAND_DELAY)
# Brightness # Brightness
if brightness is not None: if brightness is not None:
await device.set_brightness(brightness) await device.set_brightness(brightness)
logger.debug(f"Sent brightness: {brightness}") logger.debug(f"Sent brightness: {brightness}")
await asyncio.sleep(self.COMMAND_DELAY) await asyncio.sleep(self.COMMAND_DELAY)
# Color # Color
if rgb: if rgb:
await device.set_rgb(rgb) await device.set_rgb(rgb)
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() self._state.last_command_at = time.time()
# ========================================================================= # =========================================================================
# API Endpoints # API Endpoints
# ========================================================================= # =========================================================================
async def get_lighting_state(self, user=Depends(get_current_user)) -> JSONResponse: async def get_lighting_state(self, user=Depends(get_current_user)) -> JSONResponse:
"""Get the current lighting state from Redis.""" """Get the current lighting state from Redis."""
if "lighting" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "lighting" not in user.get("roles", []) and "admin" not in user.get(
"roles", []
):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
try: try:
state = self.redis_client.get(self.lighting_key) state = self.redis_client.get(self.lighting_key)
if state: if state:
return JSONResponse(content=json.loads(str(state))) return JSONResponse(content=json.loads(str(state)))
# Default state # Default state
return JSONResponse(content={ return JSONResponse(
"power": "off", content={
"brightness": 50, "power": "off",
"color": {"r": 255, "g": 255, "b": 255}, "brightness": 50,
}) "color": {"r": 255, "g": 255, "b": 255},
}
)
except Exception as e: except Exception as e:
logger.error(f"Error getting lighting state: {e}") logger.error(f"Error getting lighting state: {e}")
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
async def set_lighting_state(self, request: Request, async def set_lighting_state(
user=Depends(get_current_user)) -> JSONResponse: self, request: Request, user=Depends(get_current_user)
) -> JSONResponse:
"""Set the lighting state and apply to Cync device.""" """Set the lighting state and apply to Cync device."""
try: try:
if "lighting" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "lighting" not in user.get("roles", []) and "admin" not in user.get(
raise HTTPException(status_code=403, detail="Insufficient permissions") "roles", []
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
state = await request.json() state = await request.json()
logger.info(f"Lighting request: {state}") logger.info(f"Lighting request: {state}")
# Validate # Validate
if not isinstance(state, dict): if not isinstance(state, dict):
raise HTTPException(status_code=400, detail="State must be a JSON object") raise HTTPException(
status_code=400, detail="State must be a JSON object"
)
power, brightness, rgb = self._parse_state(state) power, brightness, rgb = self._parse_state(state)
# Save to Redis (even if device command fails) # Save to Redis (even if device command fails)
self.redis_client.set(self.lighting_key, json.dumps(state)) self.redis_client.set(self.lighting_key, json.dumps(state))
# Apply to device with retries # Apply to device with retries
await self._apply_state_with_retry(power, brightness, rgb) await self._apply_state_with_retry(power, brightness, rgb)
logger.info(f"Successfully applied state: power={power}, brightness={brightness}, rgb={rgb}") logger.info(
return JSONResponse(content={ f"Successfully applied state: power={power}, brightness={brightness}, rgb={rgb}"
"message": "Lighting state updated", )
"state": state, return JSONResponse(
}) content={
"message": "Lighting state updated",
"state": state,
}
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error setting lighting state: {e}") logger.error(f"Error setting lighting state: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
def _parse_state(self, state: dict) -> tuple[str, Optional[int], Optional[tuple]]: def _parse_state(self, state: dict) -> tuple[str, Optional[int], Optional[tuple]]:
"""Parse and validate lighting state from request.""" """Parse and validate lighting state from request."""
# Power # Power
power = state.get("power", "off") power = state.get("power", "off")
if power not in ("on", "off"): if power not in ("on", "off"):
raise HTTPException(status_code=400, detail=f"Invalid power: {power}") raise HTTPException(status_code=400, detail=f"Invalid power: {power}")
# Brightness # Brightness
brightness = None brightness = None
if "brightness" in state: if "brightness" in state:
brightness = state["brightness"] brightness = state["brightness"]
if not isinstance(brightness, (int, float)) or not (0 <= brightness <= 100): if not isinstance(brightness, (int, float)) or not (0 <= brightness <= 100):
raise HTTPException(status_code=400, detail=f"Invalid brightness: {brightness}") raise HTTPException(
status_code=400, detail=f"Invalid brightness: {brightness}"
)
brightness = int(brightness) brightness = int(brightness)
# Color # Color
rgb = None rgb = None
color = state.get("color") color = state.get("color")
if color and isinstance(color, dict) and all(k in color for k in ("r", "g", "b")): if (
color
and isinstance(color, dict)
and all(k in color for k in ("r", "g", "b"))
):
rgb = (color["r"], color["g"], color["b"]) rgb = (color["r"], color["g"], color["b"])
elif all(k in state for k in ("red", "green", "blue")): elif all(k in state for k in ("red", "green", "blue")):
rgb = (state["red"], state["green"], state["blue"]) rgb = (state["red"], state["green"], state["blue"])
if rgb: if rgb:
for i, name in enumerate(("red", "green", "blue")): for i, name in enumerate(("red", "green", "blue")):
if not isinstance(rgb[i], int) or not (0 <= rgb[i] <= 255): if not isinstance(rgb[i], int) or not (0 <= rgb[i] <= 255):
raise HTTPException(status_code=400, detail=f"Invalid {name}: {rgb[i]}") raise HTTPException(
status_code=400, detail=f"Invalid {name}: {rgb[i]}"
)
return power, brightness, rgb return power, brightness, rgb
async def _apply_state_with_retry( async def _apply_state_with_retry(
self, self,
power: str, power: str,
@@ -594,35 +614,37 @@ class Lighting:
) -> None: ) -> None:
"""Apply state to device with connection retry logic.""" """Apply state to device with connection retry logic."""
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES): for attempt in range(self.MAX_RETRIES):
try: try:
# Ensure connection (force reconnect on retries) # Ensure connection (force reconnect on retries)
await self._connect(force=(attempt > 0)) await self._connect(force=(attempt > 0))
# Send commands # Send commands
await self._send_commands(power, brightness, rgb) await self._send_commands(power, brightness, rgb)
return # Success return # Success
except (AuthFailedError, TwoFactorRequiredError) as e: except (AuthFailedError, TwoFactorRequiredError) as e:
last_error = e last_error = 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
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
logger.warning(f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}") logger.warning(
f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}"
)
# Wait before retry (exponential backoff) # Wait before retry (exponential backoff)
if attempt < self.MAX_RETRIES - 1: if attempt < self.MAX_RETRIES - 1:
wait_time = 2 ** attempt wait_time = 2**attempt
logger.info(f"Retrying in {wait_time}s...") logger.info(f"Retrying in {wait_time}s...")
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
# All retries failed # All retries failed
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")

View File

@@ -96,9 +96,11 @@ class LyricSearch(FastAPI):
handler, handler,
methods=["POST"], methods=["POST"],
include_in_schema=_schema_include, include_in_schema=_schema_include,
dependencies=[Depends(RateLimiter(times=times, seconds=seconds))] dependencies=(
if not endpoint == "typeahead/lyrics" [Depends(RateLimiter(times=times, seconds=seconds))]
else None, if not endpoint == "typeahead/lyrics"
else None
),
) )
async def typeahead_handler(self, data: ValidTypeAheadRequest) -> JSONResponse: async def typeahead_handler(self, data: ValidTypeAheadRequest) -> JSONResponse:
@@ -243,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[ line_positions: list[tuple[int, int]] = (
tuple[int, int] []
] = [] # Track where each line starts in combined text ) # Track where each line starts in combined text
combined_text_parts: list[str] = [] combined_text_parts: list[str] = []
for j in range(line_count): for j in range(line_count):

View File

@@ -75,7 +75,11 @@ class RIP(FastAPI):
app.add_api_route( app.add_api_route(
f"/{endpoint}", f"/{endpoint}",
handler, handler,
methods=["GET"] if endpoint not in ("trip/bulk_fetch", "trip/auth/check") else ["POST"], methods=(
["GET"]
if endpoint not in ("trip/bulk_fetch", "trip/auth/check")
else ["POST"]
),
include_in_schema=False, include_in_schema=False,
dependencies=dependencies, dependencies=dependencies,
) )
@@ -131,9 +135,11 @@ class RIP(FastAPI):
"started_at": job.started_at, "started_at": job.started_at,
"ended_at": job.ended_at, "ended_at": job.ended_at,
"progress": progress, "progress": progress,
"tracks": f"{succeeded_tracks} / {tracks_in}" "tracks": (
if isinstance(tracks_in, int) f"{succeeded_tracks} / {tracks_in}"
else tracks_out, if isinstance(tracks_in, int)
else tracks_out
),
"target": job.meta.get("target"), "target": job.meta.get("target"),
"quality": job.meta.get("quality", "Unknown"), "quality": job.meta.get("quality", "Unknown"),
} }
@@ -153,7 +159,7 @@ class RIP(FastAPI):
- **Response**: JSON response with artists or 404. - **Response**: JSON response with artists or 404.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
# support optional grouping to return one primary per display name # support optional grouping to return one primary per display name
# with `alternatives` for disambiguation (use ?group=true) # with `alternatives` for disambiguation (use ?group=true)
group = bool(request.query_params.get("group", False)) group = bool(request.query_params.get("group", False))
@@ -177,7 +183,7 @@ class RIP(FastAPI):
- **Response**: JSON response with albums or 404. - **Response**: JSON response with albums or 404.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
albums = await self.trip_util.get_albums_by_artist_id(artist_id) albums = await self.trip_util.get_albums_by_artist_id(artist_id)
if not albums: if not albums:
return Response(status_code=404, content="Not found") return Response(status_code=404, content="Not found")
@@ -203,7 +209,7 @@ class RIP(FastAPI):
- **Response**: JSON response with tracks or 404. - **Response**: JSON response with tracks or 404.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
tracks = await self.trip_util.get_tracks_by_album_id(album_id, quality) tracks = await self.trip_util.get_tracks_by_album_id(album_id, quality)
if not tracks: if not tracks:
return Response(status_code=404, content="Not Found") return Response(status_code=404, content="Not Found")
@@ -225,7 +231,7 @@ class RIP(FastAPI):
- **Response**: JSON response with tracks or 404. - **Response**: JSON response with tracks or 404.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
logging.critical("Searching for tracks by artist: %s, song: %s", artist, song) logging.critical("Searching for tracks by artist: %s, song: %s", artist, song)
tracks = await self.trip_util.get_tracks_by_artist_song(artist, song) tracks = await self.trip_util.get_tracks_by_artist_song(artist, song)
if not tracks: if not tracks:
@@ -252,7 +258,7 @@ class RIP(FastAPI):
- **Response**: JSON response with stream URL or 404. - **Response**: JSON response with stream URL or 404.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
track = await self.trip_util.get_stream_url_by_track_id(track_id, quality) track = await self.trip_util.get_stream_url_by_track_id(track_id, quality)
if not track: if not track:
return Response(status_code=404, content="Not found") return Response(status_code=404, content="Not found")
@@ -276,7 +282,7 @@ class RIP(FastAPI):
- **Response**: JSON response with job info or error. - **Response**: JSON response with job info or error.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
if not data or not data.track_ids or not data.target: if not data or not data.track_ids or not data.target:
return JSONResponse( return JSONResponse(
content={ content={
@@ -329,7 +335,7 @@ class RIP(FastAPI):
- **JSONResponse**: Job status and result or error. - **JSONResponse**: Job status and result or error.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
job = None job = None
try: try:
# Try direct fetch first # Try direct fetch first
@@ -368,7 +374,7 @@ class RIP(FastAPI):
- **JSONResponse**: List of jobs. - **JSONResponse**: List of jobs.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
jobs_info = [] jobs_info = []
seen = set() seen = set()
@@ -426,7 +432,7 @@ class RIP(FastAPI):
): ):
""" """
Start Tidal device authorization flow. Start Tidal device authorization flow.
Returns a URL that the user must visit to authorize the application. Returns a URL that the user must visit to authorize the application.
After visiting the URL and authorizing, call /trip/auth/check to complete. After visiting the URL and authorizing, call /trip/auth/check to complete.
@@ -434,8 +440,10 @@ class RIP(FastAPI):
- **JSONResponse**: Contains device_code and verification_url. - **JSONResponse**: Contains device_code and verification_url.
""" """
try: try:
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get(
raise HTTPException(status_code=403, detail="Insufficient permissions") "roles", []
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
device_code, verification_url = await self.trip_util.start_device_auth() device_code, verification_url = await self.trip_util.start_device_auth()
# Store device code for this session # Store device code for this session
self._pending_device_codes[user.get("sub", "default")] = device_code self._pending_device_codes[user.get("sub", "default")] = device_code
@@ -458,18 +466,20 @@ class RIP(FastAPI):
): ):
""" """
Check if Tidal device authorization is complete. Check if Tidal device authorization is complete.
Call this after the user has visited the verification URL. Call this after the user has visited the verification URL.
Returns: Returns:
- **JSONResponse**: Contains success status and message. - **JSONResponse**: Contains success status and message.
""" """
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []): if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise HTTPException(status_code=403, detail="Insufficient permissions")
device_code = self._pending_device_codes.get(user.get("sub", "default")) device_code = self._pending_device_codes.get(user.get("sub", "default"))
if not device_code: if not device_code:
return JSONResponse( return JSONResponse(
content={"error": "No pending authorization. Call /trip/auth/start first."}, content={
"error": "No pending authorization. Call /trip/auth/start first."
},
status_code=400, status_code=400,
) )
@@ -479,11 +489,18 @@ class RIP(FastAPI):
# Clear the pending code # Clear the pending code
self._pending_device_codes.pop(user.get("sub", "default"), None) self._pending_device_codes.pop(user.get("sub", "default"), None)
return JSONResponse( return JSONResponse(
content={"success": True, "message": "Tidal authorization complete!"} content={
"success": True,
"message": "Tidal authorization complete!",
}
) )
elif error == "pending": elif error == "pending":
return JSONResponse( return JSONResponse(
content={"success": False, "pending": True, "message": "Waiting for user to authorize..."} content={
"success": False,
"pending": True,
"message": "Waiting for user to authorize...",
}
) )
else: else:
return JSONResponse( return JSONResponse(

View File

@@ -6,6 +6,7 @@ from typing import Optional, Union
from utils.yt_utils import sign_video_id from utils.yt_utils import sign_video_id
from .constructors import ValidYTSearchRequest from .constructors import ValidYTSearchRequest
class YT(FastAPI): class YT(FastAPI):
""" """
YT Endpoints YT Endpoints

View File

@@ -99,7 +99,9 @@ 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 = f"postgresql+asyncpg://{POSTGRES_USER}:{encoded_password}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" DATABASE_URL: str = (
f"postgresql+asyncpg://{POSTGRES_USER}:{encoded_password}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
)
async_engine: AsyncEngine = create_async_engine( async_engine: AsyncEngine = create_async_engine(
DATABASE_URL, pool_size=20, max_overflow=10, pool_pre_ping=True, echo=False DATABASE_URL, pool_size=20, max_overflow=10, pool_pre_ping=True, echo=False
) )

View File

@@ -91,8 +91,10 @@ 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 = 'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\ check_query: str = (
'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1' <= 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()
@@ -213,10 +215,8 @@ 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_query = "INSERT INTO lyrics (src, date_retrieved, artist, song, artistsong, confidence, lyrics)\
"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,8 +260,10 @@ class Cache:
if artist == "!" and song == "!": if artist == "!" and song == "!":
random_search = True random_search = True
search_query: str = "SELECT id, artist, song, lyrics, src, confidence\ search_query: str = (
"SELECT id, artist, song, lyrics, src, confidence\
FROM lyrics ORDER BY RANDOM() LIMIT 1" 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)
@@ -320,9 +322,11 @@ 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 = 'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\ search_query: str = (
'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\
WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\ WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 10' <= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 10'
)
search_params: tuple = ( search_params: tuple = (
artist.strip(), artist.strip(),
song.strip(), song.strip(),

View File

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

View File

@@ -127,7 +127,9 @@ 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 = "SELECT id, timestamp FROM memes ORDER BY timestamp DESC LIMIT 10 OFFSET ?" query: str = (
"SELECT id, timestamp FROM memes ORDER BY timestamp DESC LIMIT 10 OFFSET ?"
)
async with await db_conn.execute(query, (offset,)) as db_cursor: async with await db_conn.execute(query, (offset,)) as db_cursor:
results = await db_cursor.fetchall() results = await db_cursor.fetchall()
for result in results: for result in results:

View File

@@ -15,11 +15,11 @@ import time
# Monkey-patch streamrip's Tidal client credentials BEFORE importing TidalClient # Monkey-patch streamrip's Tidal client credentials BEFORE importing TidalClient
import streamrip.client.tidal as _tidal_module # type: ignore # noqa: E402 import streamrip.client.tidal as _tidal_module # type: ignore # noqa: E402
_tidal_module.CLIENT_ID = "fX2JxdmntZWK0ixT" _tidal_module.CLIENT_ID = "fX2JxdmntZWK0ixT"
_tidal_module.CLIENT_SECRET = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" _tidal_module.CLIENT_SECRET = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
_tidal_module.AUTH = aiohttp.BasicAuth( _tidal_module.AUTH = aiohttp.BasicAuth(
login=_tidal_module.CLIENT_ID, login=_tidal_module.CLIENT_ID, password=_tidal_module.CLIENT_SECRET
password=_tidal_module.CLIENT_SECRET
) )
from streamrip.client import TidalClient # type: ignore # noqa: E402 from streamrip.client import TidalClient # type: ignore # noqa: E402
@@ -99,21 +99,21 @@ class SRUtil:
async def start_keepalive(self) -> None: async def start_keepalive(self) -> None:
"""Start the background keepalive task. """Start the background keepalive task.
This should be called once at startup to ensure the Tidal session This should be called once at startup to ensure the Tidal session
stays alive even during idle periods. stays alive even during idle periods.
""" """
if self._keepalive_task and not self._keepalive_task.done(): if self._keepalive_task and not self._keepalive_task.done():
logging.info("Tidal keepalive task already running") logging.info("Tidal keepalive task already running")
return return
# Ensure initial login # Ensure initial login
try: try:
await self._login_and_persist() await self._login_and_persist()
logging.info("Initial Tidal login successful") logging.info("Initial Tidal login successful")
except Exception as e: except Exception as e:
logging.warning("Initial Tidal login failed: %s", e) logging.warning("Initial Tidal login failed: %s", e)
self._keepalive_task = asyncio.create_task(self._keepalive_runner()) self._keepalive_task = asyncio.create_task(self._keepalive_runner())
logging.info("Tidal keepalive task started") logging.info("Tidal keepalive task started")
@@ -132,14 +132,14 @@ class SRUtil:
while True: while True:
try: try:
await asyncio.sleep(self.KEEPALIVE_INTERVAL) await asyncio.sleep(self.KEEPALIVE_INTERVAL)
# Check if we've had recent activity # Check if we've had recent activity
if self._last_successful_request: if self._last_successful_request:
time_since_last = time.time() - self._last_successful_request time_since_last = time.time() - self._last_successful_request
if time_since_last < self.KEEPALIVE_INTERVAL: if time_since_last < self.KEEPALIVE_INTERVAL:
# Recent activity, no need to ping # Recent activity, no need to ping
continue continue
# Check if token is expiring soon and proactively refresh # Check if token is expiring soon and proactively refresh
if self._is_token_expiring_soon(): if self._is_token_expiring_soon():
logging.info("Tidal keepalive: Token expiring soon, refreshing...") logging.info("Tidal keepalive: Token expiring soon, refreshing...")
@@ -149,7 +149,7 @@ class SRUtil:
except Exception as e: except Exception as e:
logging.warning("Tidal keepalive: Token refresh failed: %s", e) logging.warning("Tidal keepalive: Token refresh failed: %s", e)
continue continue
# Check if session is stale # Check if session is stale
if self._is_session_stale(): if self._is_session_stale():
logging.info("Tidal keepalive: Session stale, refreshing...") logging.info("Tidal keepalive: Session stale, refreshing...")
@@ -157,9 +157,11 @@ class SRUtil:
await self._login_and_persist(force=True) await self._login_and_persist(force=True)
logging.info("Tidal keepalive: Session refresh successful") logging.info("Tidal keepalive: Session refresh successful")
except Exception as e: except Exception as e:
logging.warning("Tidal keepalive: Session refresh failed: %s", e) logging.warning(
"Tidal keepalive: Session refresh failed: %s", e
)
continue continue
# Make a lightweight API call to keep the session alive # Make a lightweight API call to keep the session alive
if self.streamrip_client.logged_in: if self.streamrip_client.logged_in:
try: try:
@@ -178,7 +180,7 @@ class SRUtil:
await self._login_and_persist(force=True) await self._login_and_persist(force=True)
except Exception: except Exception:
pass pass
except asyncio.CancelledError: except asyncio.CancelledError:
logging.info("Tidal keepalive task cancelled") logging.info("Tidal keepalive task cancelled")
break break
@@ -195,7 +197,9 @@ class SRUtil:
tidal.access_token = cached.get("access_token", "") tidal.access_token = cached.get("access_token", "")
tidal.refresh_token = cached.get("refresh_token", "") tidal.refresh_token = cached.get("refresh_token", "")
tidal.token_expiry = cached.get("token_expiry", "") tidal.token_expiry = cached.get("token_expiry", "")
tidal.country_code = cached.get("country_code", os.getenv("tidal_country_code", "")) tidal.country_code = cached.get(
"country_code", os.getenv("tidal_country_code", "")
)
else: else:
tidal.user_id = os.getenv("tidal_user_id", "") tidal.user_id = os.getenv("tidal_user_id", "")
tidal.access_token = os.getenv("tidal_access_token", "") tidal.access_token = os.getenv("tidal_access_token", "")
@@ -212,7 +216,9 @@ class SRUtil:
with open(TIDAL_TOKEN_CACHE_PATH, "r") as f: with open(TIDAL_TOKEN_CACHE_PATH, "r") as f:
data = json.load(f) data = json.load(f)
# Validate required fields exist # Validate required fields exist
if all(k in data for k in ("access_token", "refresh_token", "token_expiry")): if all(
k in data for k in ("access_token", "refresh_token", "token_expiry")
):
logging.info("Loaded Tidal tokens from cache") logging.info("Loaded Tidal tokens from cache")
return data return data
except Exception as e: except Exception as e:
@@ -248,22 +254,25 @@ class SRUtil:
async def start_device_auth(self) -> tuple[str, str]: async def start_device_auth(self) -> tuple[str, str]:
"""Start device authorization flow. """Start device authorization flow.
Returns: Returns:
tuple: (device_code, verification_url) - User should visit the URL to authorize. tuple: (device_code, verification_url) - User should visit the URL to authorize.
""" """
if not hasattr(self.streamrip_client, 'session') or not self.streamrip_client.session: if (
not hasattr(self.streamrip_client, "session")
or not self.streamrip_client.session
):
self.streamrip_client.session = await self.streamrip_client.get_session() self.streamrip_client.session = await self.streamrip_client.get_session()
device_code, verification_url = await self.streamrip_client._get_device_code() device_code, verification_url = await self.streamrip_client._get_device_code()
return device_code, verification_url return device_code, verification_url
async def check_device_auth(self, device_code: str) -> tuple[bool, Optional[str]]: async def check_device_auth(self, device_code: str) -> tuple[bool, Optional[str]]:
"""Check if user has completed device authorization. """Check if user has completed device authorization.
Args: Args:
device_code: The device code from start_device_auth() device_code: The device code from start_device_auth()
Returns: Returns:
tuple: (success, error_message) tuple: (success, error_message)
- (True, None) if auth completed successfully - (True, None) if auth completed successfully
@@ -271,7 +280,7 @@ class SRUtil:
- (False, error_message) if auth failed - (False, error_message) if auth failed
""" """
status, auth_info = await self.streamrip_client._get_auth_status(device_code) status, auth_info = await self.streamrip_client._get_auth_status(device_code)
if status == 0: if status == 0:
# Success - apply new tokens # Success - apply new tokens
self._apply_new_tokens(auth_info) self._apply_new_tokens(auth_info)
@@ -300,7 +309,8 @@ class SRUtil:
# token_expiry is typically an ISO timestamp string # token_expiry is typically an ISO timestamp string
if isinstance(token_expiry, str): if isinstance(token_expiry, str):
from datetime import datetime from datetime import datetime
expiry_dt = datetime.fromisoformat(token_expiry.replace('Z', '+00:00'))
expiry_dt = datetime.fromisoformat(token_expiry.replace("Z", "+00:00"))
expiry_ts = expiry_dt.timestamp() expiry_ts = expiry_dt.timestamp()
else: else:
expiry_ts = float(token_expiry) expiry_ts = float(token_expiry)
@@ -318,14 +328,14 @@ class SRUtil:
async def _force_fresh_login(self) -> bool: async def _force_fresh_login(self) -> bool:
"""Force a complete fresh login, ignoring logged_in state. """Force a complete fresh login, ignoring logged_in state.
Returns True if login succeeded, False otherwise. Returns True if login succeeded, False otherwise.
""" """
# Reset the logged_in flag to force a fresh login # Reset the logged_in flag to force a fresh login
self.streamrip_client.logged_in = False self.streamrip_client.logged_in = False
# Close existing session if present # Close existing session if present
if hasattr(self.streamrip_client, 'session') and self.streamrip_client.session: if hasattr(self.streamrip_client, "session") and self.streamrip_client.session:
try: try:
if not self.streamrip_client.session.closed: if not self.streamrip_client.session.closed:
await self.streamrip_client.session.close() await self.streamrip_client.session.close()
@@ -333,10 +343,10 @@ class SRUtil:
logging.warning("Error closing old session: %s", e) logging.warning("Error closing old session: %s", e)
# Use object.__setattr__ to bypass type checking for session reset # Use object.__setattr__ to bypass type checking for session reset
try: try:
object.__setattr__(self.streamrip_client, 'session', None) object.__setattr__(self.streamrip_client, "session", None)
except Exception: except Exception:
pass # Session will be recreated on next login pass # Session will be recreated on next login
try: try:
logging.info("Forcing fresh Tidal login...") logging.info("Forcing fresh Tidal login...")
await self.streamrip_client.login() await self.streamrip_client.login()
@@ -345,49 +355,53 @@ class SRUtil:
logging.info("Fresh Tidal login successful") logging.info("Fresh Tidal login successful")
return True return True
except Exception as e: except Exception as e:
logging.warning("Forced Tidal login failed: %s - device re-auth may be required", e) logging.warning(
"Forced Tidal login failed: %s - device re-auth may be required", e
)
return False return False
async def _login_and_persist(self, force: bool = False) -> None: async def _login_and_persist(self, force: bool = False) -> None:
"""Login to Tidal and persist any refreshed tokens. """Login to Tidal and persist any refreshed tokens.
Args: Args:
force: If True, force a fresh login even if already logged in. force: If True, force a fresh login even if already logged in.
This method now checks for: This method now checks for:
1. Token expiry - refreshes if token is about to expire 1. Token expiry - refreshes if token is about to expire
2. Session age - refreshes if session is too old 2. Session age - refreshes if session is too old
3. logged_in state - logs in if not logged in 3. logged_in state - logs in if not logged in
If refresh fails, logs a warning but does not raise. If refresh fails, logs a warning but does not raise.
""" """
needs_login = force or not self.streamrip_client.logged_in needs_login = force or not self.streamrip_client.logged_in
# Check if token is expiring soon # Check if token is expiring soon
if not needs_login and self._is_token_expiring_soon(): if not needs_login and self._is_token_expiring_soon():
logging.info("Tidal token expiring soon, will refresh") logging.info("Tidal token expiring soon, will refresh")
needs_login = True needs_login = True
# Check if session is too old # Check if session is too old
if not needs_login and self._is_session_stale(): if not needs_login and self._is_session_stale():
logging.info("Tidal session is stale, will refresh") logging.info("Tidal session is stale, will refresh")
needs_login = True needs_login = True
if not needs_login: if not needs_login:
return return
try: try:
# Reset logged_in to ensure fresh login attempt # Reset logged_in to ensure fresh login attempt
if force or self._is_token_expiring_soon(): if force or self._is_token_expiring_soon():
self.streamrip_client.logged_in = False self.streamrip_client.logged_in = False
await self.streamrip_client.login() await self.streamrip_client.login()
self._last_login_time = time.time() self._last_login_time = time.time()
# After login, tokens may have been refreshed - persist them # After login, tokens may have been refreshed - persist them
self._save_cached_tokens() self._save_cached_tokens()
logging.info("Tidal login/refresh successful") logging.info("Tidal login/refresh successful")
except Exception as e: except Exception as e:
logging.warning("Tidal login/refresh failed: %s - device re-auth may be required", e) logging.warning(
"Tidal login/refresh failed: %s - device re-auth may be required", e
)
# Don't mark as logged in on failure - let subsequent calls retry # Don't mark as logged in on failure - let subsequent calls retry
async def rate_limited_request(self, func, *args, **kwargs): async def rate_limited_request(self, func, *args, **kwargs):
@@ -397,13 +411,15 @@ class SRUtil:
elapsed = now - self.LAST_METADATA_REQUEST elapsed = now - self.LAST_METADATA_REQUEST
if elapsed < self.METADATA_RATE_LIMIT: if elapsed < self.METADATA_RATE_LIMIT:
await asyncio.sleep(self.METADATA_RATE_LIMIT - elapsed) await asyncio.sleep(self.METADATA_RATE_LIMIT - elapsed)
# Ensure we're logged in before making the request # Ensure we're logged in before making the request
try: try:
await self._login_and_persist() await self._login_and_persist()
except Exception as e: except Exception as e:
logging.warning("Pre-request login failed in rate_limited_request: %s", e) logging.warning(
"Pre-request login failed in rate_limited_request: %s", e
)
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
self.LAST_METADATA_REQUEST = time.time() self.LAST_METADATA_REQUEST = time.time()
return result return result
@@ -432,8 +448,11 @@ class SRUtil:
try: try:
await self._login_and_persist() await self._login_and_persist()
except Exception as login_err: except Exception as login_err:
logging.warning("Pre-request login failed: %s (continuing anyway)", login_err) logging.warning(
"Pre-request login failed: %s (continuing anyway)",
login_err,
)
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
# Track successful request # Track successful request
self._last_successful_request = time.time() self._last_successful_request = time.time()
@@ -441,7 +460,12 @@ class SRUtil:
except AttributeError as e: except AttributeError as e:
# Probably missing/closed client internals: try re-login once # Probably missing/closed client internals: try re-login once
last_exc = e last_exc = e
logging.warning("AttributeError in API call (attempt %d/%d): %s", attempt + 1, retries, e) logging.warning(
"AttributeError in API call (attempt %d/%d): %s",
attempt + 1,
retries,
e,
)
try: try:
await self._force_fresh_login() await self._force_fresh_login()
except Exception: except Exception:
@@ -475,7 +499,10 @@ class SRUtil:
# Treat 401 (Unauthorized) as an auth failure: force a fresh re-login then retry # Treat 401 (Unauthorized) as an auth failure: force a fresh re-login then retry
is_401_error = ( is_401_error = (
(isinstance(e, aiohttp.ClientResponseError) and getattr(e, "status", None) == 401) (
isinstance(e, aiohttp.ClientResponseError)
and getattr(e, "status", None) == 401
)
or "401" in msg or "401" in msg
or "unauthorized" in msg.lower() or "unauthorized" in msg.lower()
) )
@@ -491,7 +518,9 @@ class SRUtil:
if login_success: if login_success:
logging.info("Forced re-login after 401 successful") logging.info("Forced re-login after 401 successful")
else: else:
logging.warning("Forced re-login after 401 failed - may need device re-auth") logging.warning(
"Forced re-login after 401 failed - may need device re-auth"
)
except Exception as login_exc: except Exception as login_exc:
logging.warning("Forced login after 401 failed: %s", login_exc) logging.warning("Forced login after 401 failed: %s", login_exc)
if attempt < retries - 1: if attempt < retries - 1:
@@ -550,9 +579,7 @@ class SRUtil:
title_match = self.is_fuzzy_match(expected_title, found_title, threshold) title_match = self.is_fuzzy_match(expected_title, found_title, threshold)
return artist_match and album_match and title_match return artist_match and album_match and title_match
def dedupe_by_key( def dedupe_by_key(self, key: str | list[str], entries: list[dict]) -> list[dict]:
self, key: str | list[str], entries: list[dict]
) -> list[dict]:
"""Return entries de-duplicated by one or more keys.""" """Return entries de-duplicated by one or more keys."""
keys = [key] if isinstance(key, str) else list(key) keys = [key] if isinstance(key, str) else list(key)
@@ -679,9 +706,11 @@ class SRUtil:
"upc": album_json.get("upc"), "upc": album_json.get("upc"),
"album_copyright": album_json.get("copyright"), "album_copyright": album_json.get("copyright"),
"album_cover_id": album_json.get("cover"), "album_cover_id": album_json.get("cover"),
"album_cover_url": f"https://resources.tidal.com/images/{album_json.get('cover')}/1280x1280.jpg" "album_cover_url": (
if album_json.get("cover") f"https://resources.tidal.com/images/{album_json.get('cover')}/1280x1280.jpg"
else None, if album_json.get("cover")
else None
),
} }
# Track-level (overrides or adds to album info) # Track-level (overrides or adds to album info)
@@ -813,7 +842,9 @@ class SRUtil:
return None return None
if not metadata: if not metadata:
return None return None
albums = self.dedupe_by_key(["title", "releaseDate"], metadata.get("albums", [])) albums = self.dedupe_by_key(
["title", "releaseDate"], metadata.get("albums", [])
)
albums_out = [ albums_out = [
{ {
"artist": ", ".join(artist["name"] for artist in album["artists"]), "artist": ", ".join(artist["name"] for artist in album["artists"]),

View File

@@ -1,35 +0,0 @@
# -----------------------
# /m/m2/ PHP handler
location ~ ^/m/m2/(.+\.php)$ {
alias /storage/music2/completed/;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME /storage/music2/completed/$1;
fastcgi_param DOCUMENT_ROOT /storage/music2/completed;
fastcgi_param SCRIPT_NAME /m/m2/$1;
}
# /m/m2/ static files
location /m/m2/ {
alias /storage/music2/completed/;
index index.php;
try_files $uri $uri/ /index.php$is_args$args;
}
# -----------------------
# /m/ PHP handler
location ~ ^/m/(.+\.php)$ {
root /var/www/codey.lol/new/public;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root/$1;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SCRIPT_NAME /m/$1;
}
# /m/ static files
location /m/ {
root /var/www/codey.lol/new/public;
index index.php;
try_files $uri $uri/ /m/index.php$is_args$args;
}

View File

@@ -7,19 +7,18 @@ import os
VIDEO_PROXY_SECRET = os.environ.get("VIDEO_PROXY_SECRET", "").encode() VIDEO_PROXY_SECRET = os.environ.get("VIDEO_PROXY_SECRET", "").encode()
def sign_video_id(video_id: Optional[str|bool]) -> str:
def sign_video_id(video_id: Optional[str | bool]) -> str:
"""Generate a signed token for a video ID.""" """Generate a signed token for a video ID."""
if not VIDEO_PROXY_SECRET or not video_id: if not VIDEO_PROXY_SECRET or not video_id:
return "" # Return empty if no secret configured return "" # Return empty if no secret configured
timestamp = int(time.time() * 1000) # milliseconds to match JS Date.now() timestamp = int(time.time() * 1000) # milliseconds to match JS Date.now()
payload = f"{video_id}:{timestamp}" payload = f"{video_id}:{timestamp}"
signature = hmac.new( signature = hmac.new(
VIDEO_PROXY_SECRET, VIDEO_PROXY_SECRET, payload.encode(), hashlib.sha256
payload.encode(),
hashlib.sha256
).hexdigest() ).hexdigest()
token_data = f"{payload}:{signature}" token_data = f"{payload}:{signature}"
# base64url encode (no padding, to match JS base64url) # base64url encode (no padding, to match JS base64url)
return base64.urlsafe_b64encode(token_data.encode()).decode().rstrip("=") return base64.urlsafe_b64encode(token_data.encode()).decode().rstrip("=")