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
|
||||
|
||||
265
endpoints/rip.py
265
endpoints/rip.py
@@ -16,7 +16,7 @@ from rq.registry import (
|
||||
FailedJobRegistry,
|
||||
ScheduledJobRegistry,
|
||||
)
|
||||
from utils.rip_background import bulk_download
|
||||
from utils.rip_background import bulk_download, bulk_video_download
|
||||
from lyric_search.sources import private
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel
|
||||
@@ -30,6 +30,11 @@ class ValidBulkFetchRequest(BaseModel):
|
||||
quality: Literal["FLAC", "Lossy"] = "FLAC"
|
||||
|
||||
|
||||
class ValidVideoBulkFetchRequest(BaseModel):
|
||||
video_ids: list[int]
|
||||
target: str
|
||||
|
||||
|
||||
class RIP(FastAPI):
|
||||
"""
|
||||
Ripping Endpoints
|
||||
@@ -65,6 +70,13 @@ class RIP(FastAPI):
|
||||
"trip/jobs/list": self.job_list_handler,
|
||||
"trip/auth/start": self.tidal_auth_start_handler,
|
||||
"trip/auth/check": self.tidal_auth_check_handler,
|
||||
# Video endpoints - order matters: specific routes before parameterized ones
|
||||
"trip/videos/search": self.video_search_handler,
|
||||
"trip/videos/artist/{artist_id:path}": self.videos_by_artist_handler,
|
||||
"trip/videos/bulk_fetch": self.video_bulk_fetch_handler,
|
||||
"trip/video/{video_id:path}/stream": self.video_stream_handler,
|
||||
"trip/video/{video_id:path}/download": self.video_download_handler,
|
||||
"trip/video/{video_id:path}": self.video_metadata_handler,
|
||||
}
|
||||
|
||||
# Store pending device codes for auth flow
|
||||
@@ -77,10 +89,10 @@ class RIP(FastAPI):
|
||||
handler,
|
||||
methods=(
|
||||
["GET"]
|
||||
if endpoint not in ("trip/bulk_fetch", "trip/auth/check")
|
||||
if endpoint not in ("trip/bulk_fetch", "trip/auth/check", "trip/videos/bulk_fetch")
|
||||
else ["POST"]
|
||||
),
|
||||
include_in_schema=False,
|
||||
include_in_schema=True,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
@@ -530,3 +542,250 @@ class RIP(FastAPI):
|
||||
content={"error": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Video Endpoints
|
||||
# =========================================================================
|
||||
|
||||
async def video_search_handler(
|
||||
self, q: str, request: Request, limit: int = 50, user=Depends(get_current_user)
|
||||
) -> Response:
|
||||
"""
|
||||
Search for videos by query string.
|
||||
|
||||
Parameters:
|
||||
- **q** (str): Search query (artist, song title, etc.)
|
||||
- **limit** (int): Maximum number of results (default 50).
|
||||
- **request** (Request): The request object.
|
||||
- **user**: Current user (dependency).
|
||||
|
||||
Returns:
|
||||
- **Response**: JSON response with video results or 404.
|
||||
"""
|
||||
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
if not q or not q.strip():
|
||||
return JSONResponse(
|
||||
content={"error": "Query parameter 'q' is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
videos = await self.trip_util.search_videos(q.strip(), limit=limit)
|
||||
if not videos:
|
||||
return JSONResponse(
|
||||
content={"error": "No videos found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"videos": videos})
|
||||
|
||||
async def video_metadata_handler(
|
||||
self, video_id: str, request: Request, user=Depends(get_current_user)
|
||||
) -> Response:
|
||||
"""
|
||||
Get metadata for a specific video.
|
||||
|
||||
Parameters:
|
||||
- **video_id** (str): The Tidal video ID.
|
||||
- **request** (Request): The request object.
|
||||
- **user**: Current user (dependency).
|
||||
|
||||
Returns:
|
||||
- **Response**: JSON response with video metadata or 404.
|
||||
"""
|
||||
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
try:
|
||||
vid_id = int(video_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
content={"error": "Invalid video ID"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
metadata = await self.trip_util.get_video_metadata(vid_id)
|
||||
if not metadata:
|
||||
return JSONResponse(
|
||||
content={"error": "Video not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content=metadata)
|
||||
|
||||
async def video_stream_handler(
|
||||
self, video_id: str, request: Request, user=Depends(get_current_user)
|
||||
) -> Response:
|
||||
"""
|
||||
Get the stream URL for a video.
|
||||
|
||||
Parameters:
|
||||
- **video_id** (str): The Tidal video ID.
|
||||
- **request** (Request): The request object.
|
||||
- **user**: Current user (dependency).
|
||||
|
||||
Returns:
|
||||
- **Response**: JSON response with stream URL or 404.
|
||||
"""
|
||||
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
try:
|
||||
vid_id = int(video_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
content={"error": "Invalid video ID"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
stream_url = await self.trip_util.get_video_stream_url(vid_id)
|
||||
if not stream_url:
|
||||
return JSONResponse(
|
||||
content={"error": "Video stream not available"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"stream_url": stream_url})
|
||||
|
||||
async def videos_by_artist_handler(
|
||||
self, artist_id: str, request: Request, user=Depends(get_current_user)
|
||||
) -> Response:
|
||||
"""
|
||||
Get videos by artist ID.
|
||||
|
||||
Parameters:
|
||||
- **artist_id** (str): The Tidal artist ID.
|
||||
- **request** (Request): The request object.
|
||||
- **user**: Current user (dependency).
|
||||
|
||||
Returns:
|
||||
- **Response**: JSON response with artist's videos or 404.
|
||||
"""
|
||||
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
try:
|
||||
art_id = int(artist_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
content={"error": "Invalid artist ID"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
videos = await self.trip_util.get_videos_by_artist_id(art_id)
|
||||
if not videos:
|
||||
return JSONResponse(
|
||||
content={"error": "No videos found for this artist"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"videos": videos})
|
||||
|
||||
async def video_download_handler(
|
||||
self, video_id: str, request: Request, user=Depends(get_current_user)
|
||||
) -> Response:
|
||||
"""
|
||||
Download a video file.
|
||||
|
||||
Parameters:
|
||||
- **video_id** (str): The Tidal video ID.
|
||||
- **request** (Request): The request object.
|
||||
- **user**: Current user (dependency).
|
||||
|
||||
Returns:
|
||||
- **Response**: The video file as a streaming response.
|
||||
"""
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
|
||||
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
try:
|
||||
vid_id = int(video_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
content={"error": "Invalid video ID"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get video metadata for filename
|
||||
metadata = await self.trip_util.get_video_metadata(vid_id)
|
||||
if not metadata:
|
||||
return JSONResponse(
|
||||
content={"error": "Video not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Download the video
|
||||
file_path = await self.trip_util.download_video(vid_id)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return JSONResponse(
|
||||
content={"error": "Failed to download video"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Generate a nice filename
|
||||
artist = metadata.get("artist", "Unknown")
|
||||
title = metadata.get("title", f"video_{vid_id}")
|
||||
# Sanitize filename
|
||||
safe_filename = f"{artist} - {title}.mp4".replace("/", "-").replace("\\", "-")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=safe_filename,
|
||||
media_type="video/mp4",
|
||||
)
|
||||
|
||||
async def video_bulk_fetch_handler(
|
||||
self,
|
||||
data: ValidVideoBulkFetchRequest,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""
|
||||
Bulk fetch a list of video IDs.
|
||||
|
||||
Parameters:
|
||||
- **data** (ValidVideoBulkFetchRequest): Bulk video fetch request data.
|
||||
- **request** (Request): The request object.
|
||||
- **user**: Current user (dependency).
|
||||
|
||||
Returns:
|
||||
- **Response**: JSON response with job info or error.
|
||||
"""
|
||||
if "trip" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
if not data or not data.video_ids or not data.target:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"err": True,
|
||||
"errorText": "Invalid data",
|
||||
}
|
||||
)
|
||||
video_ids = data.video_ids
|
||||
target = data.target
|
||||
job = self.task_queue.enqueue(
|
||||
bulk_video_download,
|
||||
args=(video_ids,),
|
||||
job_timeout=28800, # 8 hours for videos
|
||||
failure_ttl=86400,
|
||||
result_ttl=-1,
|
||||
meta={
|
||||
"progress": 0,
|
||||
"status": "Queued",
|
||||
"target": target,
|
||||
"videos_in": len(video_ids),
|
||||
"type": "video",
|
||||
},
|
||||
)
|
||||
self.redis_conn.lpush("enqueued_job_ids", job.id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"job_id": job.id,
|
||||
"status": "Queued",
|
||||
"videos": len(video_ids),
|
||||
"target": target,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user