204 lines
6.9 KiB
Python
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")
|