Files
streamrip-ui/backend/app/api/downloads.py
T

204 lines
6.9 KiB
Python

"""Download queue with SSE progress."""
import asyncio
import json
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.core import jobstore, settings
from app.services import streamrip_service
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
class DownloadRequest(BaseModel):
source: Literal["deezer"] = "deezer"
media_type: Literal["track", "album", "artist", "playlist"] | None = None
id: str | None = None
url: str | None = None
quality: int | None = Field(default=None, ge=0, le=2)
codec: str | None = None
folder: str | None = None
no_db: bool = False
def _validate_codec(codec: str | None) -> None:
if codec and codec.upper() not in ("FLAC", "ALAC", "OPUS", "MP3", "VORBIS", "AAC"):
raise HTTPException(status_code=422, detail="codec must be one of FLAC, ALAC, OPUS, MP3, VORBIS, AAC")
def _validate_folder(folder: str | None) -> None:
import os
if not folder:
return
fp = Path(folder).expanduser()
if not fp.is_dir() or not os.access(fp, os.W_OK):
raise HTTPException(status_code=422, detail="folder must exist and be writable")
def _validate_url(url: str) -> str:
"""Return normalized URL or raise 422 (Deezer only)."""
from streamrip.rip.parse_url import parse_url
parsed = parse_url(url)
if parsed is None:
raise HTTPException(status_code=422, detail="URL nicht erkannt (Deezer-Album/Track/Artist/Playlist erwartet)")
if parsed.source != "deezer":
raise HTTPException(status_code=422, detail=f"Nur Deezer wird unterstützt (erkannt: {parsed.source})")
return url
async def _run_job(job_id: str, params: dict):
async with jobstore._worker_sem:
if jobstore.get_job(job_id)["status"] == "cancelled":
return
label = params.get("url") or f"{params['media_type']} {params['id']}"
jobstore.update_job(job_id, status="running")
jobstore.append_log(job_id, f"Starting {label}")
try:
common = dict(
quality=params.get("quality"),
codec=params.get("codec"),
folder=params.get("folder"),
no_db=params.get("no_db", False),
on_log=lambda line: jobstore.append_log(job_id, line),
)
if params.get("url"):
await streamrip_service.download_by_url(url=params["url"], **common)
else:
await streamrip_service.download_by_id(
source=params["source"],
media_type=params["media_type"],
item_id=params["id"],
**common,
)
jobstore.update_job(job_id, status="done", progress=100)
jobstore.append_log(job_id, "Done.")
except asyncio.CancelledError:
jobstore.update_job(job_id, status="cancelled")
jobstore.append_log(job_id, "Abgebrochen.")
raise
except Exception as e:
jobstore.update_job(job_id, status="failed", error=str(e))
jobstore.append_log(job_id, f"FAILED: {e}")
finally:
jobstore.pop_task(job_id)
async def enqueue_download(params: dict) -> dict:
job = jobstore.new_job(params)
task = asyncio.create_task(_run_job(job["job_id"], params))
jobstore.set_task(job["job_id"], task)
return {"job_id": job["job_id"], "status": job["status"]}
@router.delete("/{job_id}")
def cancel_download(job_id: str):
job, task = jobstore.try_cancel(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Unknown job")
if task is None and job["status"] in jobstore.TERMINAL and "Abbruch angefordert." not in job["logs"]:
raise HTTPException(status_code=409, detail=f"Job already {job['status']}")
if task is not None:
task.cancel()
return jobstore.get_job(job_id)
@router.post("")
async def create_download(req: DownloadRequest):
data = req.model_dump()
_validate_codec(data.get("codec"))
_validate_folder(data.get("folder"))
if data.get("url"):
data["url"] = _validate_url(data["url"])
data["media_type"] = data.get("media_type") or "url"
data["id"] = data.get("id") or data["url"]
else:
if not data.get("media_type") or not data.get("id"):
raise HTTPException(status_code=422, detail="media_type + id oder url erforderlich")
return await enqueue_download(data)
class BatchItem(BaseModel):
media_type: str | None = None
id: str | None = None
url: str | None = None
class BatchRequest(BaseModel):
items: list[BatchItem] = Field(..., min_length=1, max_length=50)
quality: int | None = Field(default=None, ge=0, le=2)
codec: str | None = None
folder: str | None = None
no_db: bool = False
@router.post("/batch")
async def create_batch(req: BatchRequest):
_validate_codec(req.codec)
_validate_folder(req.folder)
job_ids: list[str] = []
rejected: list[dict] = []
for i, item in enumerate(req.items):
try:
if item.url:
url = _validate_url(item.url)
params = {
"source": "deezer",
"media_type": "url",
"id": url,
"url": url,
}
else:
if item.media_type not in ("track", "album", "artist", "playlist") or not item.id:
raise HTTPException(status_code=422, detail="media_type + id oder url erforderlich")
params = {
"source": "deezer",
"media_type": item.media_type,
"id": item.id,
}
params.update(
quality=req.quality, codec=req.codec, folder=req.folder, no_db=req.no_db
)
res = await enqueue_download(params)
job_ids.append(res["job_id"])
except HTTPException as e:
rejected.append({"index": i, "reason": e.detail})
return {"job_ids": job_ids, "rejected": rejected}
@router.get("")
def get_downloads():
return {"jobs": jobstore.list_jobs()}
@router.get("/{job_id}")
def get_download(job_id: str):
job = jobstore.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Unknown job")
return job
@router.get("/{job_id}/events")
async def download_events(job_id: str):
if not jobstore.get_job(job_id):
raise HTTPException(status_code=404, detail="Unknown job")
async def gen():
while True:
job = jobstore.get_job(job_id)
if not job:
break
yield f"data: {json.dumps(job)}\n\n"
if job["status"] in jobstore.TERMINAL:
break
await asyncio.sleep(0.5)
return StreamingResponse(gen(), media_type="text/event-stream")