Add bulk video download functionality

- Implemented `bulk_video_download` function to handle video downloads, including metadata fetching, HLS stream handling, and tarball creation.
- Enhanced `bulk_download` function in `rip_background.py` to improve error logging with formatted track descriptions.
- Added video search and metadata retrieval methods in `sr_wrapper.py` for better integration with Tidal's video API.
- Updated Tidal client credentials
This commit is contained in:
2026-02-18 13:38:26 -05:00
parent 9d16c96490
commit d6689b9c38
4 changed files with 1257 additions and 81 deletions

View File

@@ -18,6 +18,7 @@ import json
import os
import time
import asyncio
import ssl
from typing import Optional, Any
from dataclasses import dataclass, field
from enum import Enum
@@ -82,10 +83,14 @@ class Lighting:
# Configuration
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
COMMAND_TIMEOUT = 10 # Max seconds for a single device command
COMMAND_LOCK_TIMEOUT = 30 # Max seconds to wait for command lock
COMMAND_DELAY = 0.3 # Delay between sequential commands
MAX_RETRIES = 3
MAX_CONSECUTIVE_FAILURES = 5 # Force full reconnect after this many failures
HEALTH_CHECK_INTERVAL = 30 # Check connection health every 30s
MAX_CONNECTION_AGE = 1800 # Force reconnect after 30 minutes
MAX_IDLE_SECONDS = 300 # Reconnect if idle for 5 minutes
TWO_FA_POLL_INTERVAL = 5 # Poll for 2FA code every 5 seconds
TWO_FA_TIMEOUT = 300 # 5 minutes to enter 2FA code
REDIS_2FA_KEY = "cync:2fa_code"
@@ -113,6 +118,7 @@ class Lighting:
# Connection state
self._state = CyncConnectionState()
self._connection_lock = asyncio.Lock()
self._command_lock = asyncio.Lock()
self._health_task: Optional[asyncio.Task] = None
self._2fa_task: Optional[asyncio.Task] = None
@@ -323,6 +329,10 @@ class Lighting:
logger.info("Cync TCP manager not connected; will reconnect")
return False
if self._is_connection_stale():
logger.info("Cync connection is stale; will reconnect")
return False
# Check token expiry
if self._is_token_expired():
logger.info("Token expired or expiring soon")
@@ -330,6 +340,18 @@ class Lighting:
return True
def _is_connection_stale(self) -> bool:
"""Reconnect if the connection is too old or has been idle too long."""
now = time.time()
if self._state.connected_at and now - self._state.connected_at > self.MAX_CONNECTION_AGE:
return True
last_activity = self._state.last_successful_command or self._state.connected_at
if last_activity and now - last_activity > self.MAX_IDLE_SECONDS:
return True
return False
def _is_tcp_connected(self) -> bool:
"""Best-effort check that the pycync TCP connection is alive."""
client = getattr(self._state.cync_api, "_command_client", None)
@@ -359,6 +381,24 @@ class Lighting:
return True
def _is_ssl_closed_error(self, error: Exception) -> bool:
"""Detect SSL/TCP connection closed errors."""
if isinstance(
error,
(
ssl.SSLError,
aiohttp.ClientConnectionError,
ConnectionResetError,
BrokenPipeError,
ConnectionError,
asyncio.IncompleteReadError,
),
):
return True
message = str(error).lower()
return "ssl connection is closed" in message or "connection reset" in message
def _is_token_expired(self) -> bool:
"""Check if token is expired or will expire soon."""
if not self._state.user:
@@ -433,9 +473,12 @@ class Lighting:
)
try:
self._state.user = await self._state.auth.login()
self._save_cached_token(self._state.user)
logger.info("Cync login successful")
if self._state.auth:
self._state.user = await self._state.auth.login()
self._save_cached_token(self._state.user)
logger.info("Cync login successful")
else:
raise TwoFactorRequiredError("Unknown 2FA error")
except TwoFactorRequiredError:
await self._handle_2fa()
except AuthFailedError as e:
@@ -644,6 +687,11 @@ class Lighting:
needs_reconnect = True
reason = "TCP connection lost"
# Reconnect if connection is stale
elif self._is_connection_stale():
needs_reconnect = True
reason = "connection stale"
if needs_reconnect:
logger.warning(f"Health monitor triggering reconnection: {reason}")
self._state.status = ConnectionStatus.CONNECTING
@@ -707,26 +755,38 @@ class Lighting:
device = await self._get_device()
logger.info(f"Sending commands to device: {device.name}")
# Power
if power == "on":
await device.turn_on()
logger.debug("Sent turn_on")
else:
await device.turn_off()
logger.debug("Sent turn_off")
# Power - with timeout to prevent hangs
try:
if power == "on":
await asyncio.wait_for(device.turn_on(), timeout=self.COMMAND_TIMEOUT)
logger.debug("Sent turn_on")
else:
await asyncio.wait_for(device.turn_off(), timeout=self.COMMAND_TIMEOUT)
logger.debug("Sent turn_off")
except asyncio.TimeoutError:
raise TimeoutError(f"Power command timed out after {self.COMMAND_TIMEOUT}s")
await asyncio.sleep(self.COMMAND_DELAY)
# Brightness
# Brightness - with timeout
if brightness is not None:
await device.set_brightness(brightness)
logger.debug(f"Sent brightness: {brightness}")
try:
await asyncio.wait_for(device.set_brightness(brightness), timeout=self.COMMAND_TIMEOUT)
logger.debug(f"Sent brightness: {brightness}")
except asyncio.TimeoutError:
raise TimeoutError(f"Brightness command timed out after {self.COMMAND_TIMEOUT}s")
await asyncio.sleep(self.COMMAND_DELAY)
# Color
# Color - with timeout
if rgb:
await device.set_rgb(rgb)
logger.debug(f"Sent RGB: {rgb}")
await asyncio.sleep(self.COMMAND_DELAY)
try:
await asyncio.wait_for(device.set_rgb(rgb), timeout=self.COMMAND_TIMEOUT)
logger.debug(f"Sent RGB: {rgb}")
except asyncio.TimeoutError:
raise TimeoutError(f"RGB command timed out after {self.COMMAND_TIMEOUT}s")
# Verify connection is still alive after sending
if not self._is_tcp_connected():
raise ConnectionError("Cync TCP connection lost after command")
# Track success
now = time.time()
@@ -851,46 +911,65 @@ class Lighting:
"""Apply state to device with connection retry logic."""
last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
try:
# Ensure connection (force reconnect on retries)
await self._connect(force=(attempt > 0))
# Try to acquire command lock with timeout to prevent indefinite waits
try:
await asyncio.wait_for(
self._command_lock.acquire(), timeout=self.COMMAND_LOCK_TIMEOUT
)
except asyncio.TimeoutError:
logger.error(f"Command lock acquisition timed out after {self.COMMAND_LOCK_TIMEOUT}s")
raise TimeoutError("Another command is in progress and not responding")
# Send commands
await self._send_commands(power, brightness, rgb)
return # Success
try:
for attempt in range(self.MAX_RETRIES):
try:
# Ensure connection (force reconnect on retries)
await self._connect(force=(attempt > 0))
except (AuthFailedError, TwoFactorRequiredError) as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning(f"Auth error on attempt {attempt + 1}: {e}")
self._clear_cached_token()
# Send commands
await self._send_commands(power, brightness, rgb)
return # Success
except TimeoutError as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning(f"Timeout on attempt {attempt + 1}: {e}")
except (AuthFailedError, TwoFactorRequiredError) as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning(f"Auth error on attempt {attempt + 1}: {e}")
self._clear_cached_token()
except Exception as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning(
f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}"
)
except TimeoutError as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
logger.warning(f"Timeout on attempt {attempt + 1}: {e}")
# Wait before retry (exponential backoff)
if attempt < self.MAX_RETRIES - 1:
wait_time = 2**attempt
logger.info(f"Retrying in {wait_time}s...")
await asyncio.sleep(wait_time)
except Exception as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
if self._is_ssl_closed_error(e) or not self._is_tcp_connected():
logger.warning(
"Connection lost during command; will reconnect and retry"
)
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
else:
logger.warning(
f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}"
)
# All retries failed
self._update_status_in_redis()
logger.error(f"All {self.MAX_RETRIES} attempts failed")
raise last_error or RuntimeError("Failed to apply lighting state")
# Wait before retry (exponential backoff)
if attempt < self.MAX_RETRIES - 1:
wait_time = 2**attempt
logger.info(f"Retrying in {wait_time}s...")
await asyncio.sleep(wait_time)
# All retries failed
self._update_status_in_redis()
logger.error(f"All {self.MAX_RETRIES} attempts failed")
raise last_error or RuntimeError("Failed to apply lighting state")
finally:
self._command_lock.release()
# =========================================================================
# Connection Status & 2FA Endpoints