feat(downloads): ad-hoc clips cut straight from a YouTube video or a Plex item
POST /api/downloads/clip takes a source (a YouTube id or a Plex rating key), a range of at most ten minutes and, for Plex, the audio track being heard. The clip becomes a per-user asset in the library like an editor clip, shareable as soon as it is ready. A YouTube clip fetches only its range (yt-dlp download_ranges: 3-7 s whatever the video's length); a Plex clip reads the library file in place. Both then go through one re-encode: frame-exact at the marks, H.264 at most 1080p with HDR tonemapped to SDR, stereo AAC, no chapter track, .mp4. Job kinds are a JobKind StrEnum now, and Re-download refuses every kind but a download.
This commit is contained in:
@@ -8,6 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name),
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Literal
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -16,7 +17,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -27,13 +28,14 @@ from app import audit, sysconfig
|
||||
from app.auth import admin_user, current_user
|
||||
from app.config import settings
|
||||
from app.db import get_db, release_read_connection
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import credentials as dlcreds
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.status import JobStatus
|
||||
from app.downloads.status import NOT_REFETCHABLE, JobStatus
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -576,6 +578,34 @@ def enqueue_edit(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
class EnqueueClipIn(BaseModel):
|
||||
source: Literal["youtube", "plex"]
|
||||
# The YouTube video id or the Plex rating key.
|
||||
ref: str = Field(max_length=32)
|
||||
start_s: float
|
||||
end_s: float
|
||||
# Plex only: which of the file's audio tracks (0-based, in file order) — the one being heard.
|
||||
audio_track: int | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@router.post("/clip")
|
||||
def enqueue_clip(
|
||||
body: EnqueueClipIn, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Queue an ad-hoc clip: the marked range of a YouTube video or a Plex item, cut straight from
|
||||
the source into a new asset in this user's library (downloads/clip)."""
|
||||
try:
|
||||
spec = clipmod.normalize(body.source, body.ref, body.start_s, body.end_s, body.audio_track)
|
||||
job = service.enqueue_clip(db, user.id, spec, display_text(body.display_name))
|
||||
except clipmod.ClipError as e:
|
||||
status = 404 if e.reason == "not_found" else 422
|
||||
raise HTTPException(status_code=status, detail={"code": "clip", "reason": e.reason}) from e
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
def _quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
||||
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
|
||||
@@ -768,9 +798,9 @@ def redownload_download(
|
||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||
was its last holder."""
|
||||
job = _own_job_for_update(db, user, job_id)
|
||||
if job.job_kind in ("edit", "upload"):
|
||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
||||
if job.job_kind in NOT_REFETCHABLE:
|
||||
# None has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||
# source, an ad-hoc clip is a range of a source the clip itself does not re-fetch.
|
||||
raise refused(409, "not_redownloadable")
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
|
||||
raise refused(409, "still_downloading")
|
||||
|
||||
Reference in New Issue
Block a user