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

View File

@@ -141,7 +141,8 @@ End Blacklisted Routes
Actionable Routes Actionable Routes
""" """
_routes.update({ _routes.update(
{
"randmsg": importlib.import_module("endpoints.rand_msg").RandMsg( "randmsg": importlib.import_module("endpoints.rand_msg").RandMsg(
app, util, constants app, util, constants
), ),
@@ -158,7 +159,8 @@ _routes.update({
"lighting": importlib.import_module("endpoints.lighting").Lighting( "lighting": importlib.import_module("endpoints.lighting").Lighting(
app, util, constants 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
@@ -73,8 +74,7 @@ class Lighting:
# 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"
@@ -208,7 +208,7 @@ class Lighting:
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:
@@ -242,7 +242,7 @@ class Lighting:
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:
@@ -255,19 +255,19 @@ class Lighting:
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)
@@ -341,8 +341,7 @@ class Lighting:
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:
@@ -365,7 +364,7 @@ class Lighting:
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]:
@@ -374,15 +373,15 @@ class Lighting:
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}")
@@ -392,13 +391,13 @@ class Lighting:
"""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:
@@ -450,12 +449,12 @@ class Lighting:
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}"
) )
@@ -506,7 +505,9 @@ class Lighting:
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)
@@ -514,27 +515,34 @@ class Lighting:
return JSONResponse(content=json.loads(str(state))) return JSONResponse(content=json.loads(str(state)))
# Default state # Default state
return JSONResponse(content={ return JSONResponse(
content={
"power": "off", "power": "off",
"brightness": 50, "brightness": 50,
"color": {"r": 255, "g": 255, "b": 255}, "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(
"roles", []
):
raise HTTPException(status_code=403, detail="Insufficient permissions") 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)
@@ -544,11 +552,15 @@ class Lighting:
# 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}"
)
return JSONResponse(
content={
"message": "Lighting state updated", "message": "Lighting state updated",
"state": state, "state": state,
}) }
)
except HTTPException: except HTTPException:
raise raise
@@ -568,13 +580,19 @@ class Lighting:
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"])
@@ -582,7 +600,9 @@ class Lighting:
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
@@ -615,7 +635,9 @@ class Lighting:
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:

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=(
[Depends(RateLimiter(times=times, seconds=seconds))]
if not endpoint == "typeahead/lyrics" if not endpoint == "typeahead/lyrics"
else None, 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": (
f"{succeeded_tracks} / {tracks_in}"
if isinstance(tracks_in, int) if isinstance(tracks_in, int)
else tracks_out, 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"),
} }
@@ -434,7 +440,9 @@ 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(
"roles", []
):
raise HTTPException(status_code=403, detail="Insufficient permissions") 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
@@ -469,7 +477,9 @@ class RIP(FastAPI):
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,8 +111,7 @@ 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:
@@ -126,7 +125,6 @@ class DataUtils:
""", """,
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(
r"(\d?)(Embed\b)", flags=regex.IGNORECASE r"(\d?)(Embed\b)", flags=regex.IGNORECASE

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
@@ -157,7 +157,9 @@ 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
@@ -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:
@@ -252,7 +258,10 @@ class SRUtil:
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()
@@ -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)
@@ -325,7 +335,7 @@ class SRUtil:
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,7 +343,7 @@ 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
@@ -345,7 +355,9 @@ 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:
@@ -387,7 +399,9 @@ class SRUtil:
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):
@@ -402,7 +416,9 @@ class SRUtil:
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()
@@ -432,7 +448,10 @@ 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
@@ -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": (
f"https://resources.tidal.com/images/{album_json.get('cover')}/1280x1280.jpg"
if album_json.get("cover") if album_json.get("cover")
else None, 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,6 +7,7 @@ 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:
@@ -15,9 +16,7 @@ def sign_video_id(video_id: Optional[str|bool]) -> str:
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}"