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

@@ -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,
}
)