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:
2026-10-10 02:31:38 +02:00
parent b16cf7668c
commit 14598ec320
18 changed files with 963 additions and 32 deletions
+35 -5
View File
@@ -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")