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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user