- lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data
stays English, the catalog is named at its one source, useTags), built-in download profiles,
YouTube topics; held key-for-key to the backend's own lists by backendIds.contract.
- Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's
own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…)
from the catalog.
- Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's
server-language displayTitle; no invented "Audio N" on the server.
- Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share
notice carries the title.
- Scheduler: the job result rides along as data and is named per key (dashboard + run notice).
- Invite status named; a render crash's notice no longer shows the engine's English message.
- Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic.
1235 lines
46 KiB
Python
1235 lines
46 KiB
Python
"""Download center HTTP API — the per-user surface over the worker/cache/quota layers.
|
|
|
|
All routes are behind `require_human` (the shared demo account can't spend server disk / needs a
|
|
real identity). Admin routes add `admin_user`. Live job state is polled by the frontend (the
|
|
worker is a separate process); this module never downloads — it enqueues, manages job state,
|
|
serves finished files (range-aware, with the user's custom display name), and shares them.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from collections.abc import AsyncIterator
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
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 sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit, sysconfig
|
|
from app.auth import admin_user, require_human
|
|
from app.config import settings
|
|
from app.db import get_db, release_read_connection
|
|
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.models import (
|
|
DownloadJob,
|
|
DownloadLink,
|
|
DownloadProfile,
|
|
DownloadQuota,
|
|
DownloadShare,
|
|
MediaAsset,
|
|
User,
|
|
display_text,
|
|
)
|
|
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
|
|
from app.security import hash_password
|
|
from app.userscope import is_messageable_user, messageable_clauses
|
|
from app.utils import iso
|
|
from app.refusal import refused
|
|
|
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
|
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
|
|
|
_VIDEO_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
|
|
|
# Cap the per-user job listings so a heavy library can't return an unbounded result set. The
|
|
# Download Center renders newest-first; 500 is far beyond any real manual-download history (and
|
|
# matches the admin listing's own cap).
|
|
_MAX_LIST = 500
|
|
|
|
|
|
# --- source + serialization ----------------------------------------------------------------
|
|
|
|
|
|
def resolve_source(raw: str) -> tuple[str, str]:
|
|
"""Map a user-supplied id/URL to (source_kind, source_ref). YouTube is normalized to its
|
|
11-char id (so the cache dedups across watch/youtu.be/shorts URLs); anything else is kept
|
|
as a raw URL for yt-dlp's generic extractor."""
|
|
raw = (raw or "").strip()
|
|
if not raw:
|
|
raise refused(400, "link_required")
|
|
if _VIDEO_ID.match(raw):
|
|
return "youtube", raw
|
|
if raw.startswith(("http://", "https://")):
|
|
# `urlparse` raises ValueError on a malformed authority ("http://[::1/x"), and this runs
|
|
# BEFORE the urlguard check that would otherwise turn it into a structured refusal — so
|
|
# unguarded, that typo escapes as a 500. Answered in urlguard's own shape, because it is the
|
|
# same typo class: ":99999" is caught there and localized, and one of the two arriving as a
|
|
# bare English string would be exactly the split the canonical-patterns table forbids.
|
|
try:
|
|
u = urlparse(raw)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={"code": "url_blocked", "reason": urlguard.REASON_MALFORMED},
|
|
) from exc
|
|
host = u.netloc.lower()
|
|
if "youtube.com" in host:
|
|
if u.path.startswith(("/shorts/", "/embed/", "/live/")):
|
|
vid = u.path.split("/")[2] if len(u.path.split("/")) > 2 else ""
|
|
if _VIDEO_ID.match(vid):
|
|
return "youtube", vid
|
|
qs = parse_qs(u.query).get("v", [])
|
|
if qs and _VIDEO_ID.match(qs[0]):
|
|
return "youtube", qs[0]
|
|
if "youtu.be" in host:
|
|
vid = u.path.lstrip("/").split("/")[0]
|
|
if _VIDEO_ID.match(vid):
|
|
return "youtube", vid
|
|
return "url", raw
|
|
raise refused(400, "invalid_link")
|
|
|
|
|
|
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
|
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
|
return {
|
|
"id": job.id,
|
|
"status": job.status,
|
|
"progress": job.progress,
|
|
"speed_bps": job.speed_bps,
|
|
"eta_s": job.eta_s,
|
|
"phase": job.phase,
|
|
"error": job.error,
|
|
"error_code": job.error_code,
|
|
"error_detail": job.error_detail,
|
|
"queue_pos": job.queue_pos,
|
|
"created_at": iso(job.created_at),
|
|
"source_kind": job.source_kind,
|
|
"source_ref": job.source_ref,
|
|
"source_url": service.reference_url(job, asset),
|
|
# A locally-generated poster to fall back to when the source had no thumbnail.
|
|
"poster_url": f"/api/downloads/{job.id}/poster.jpg"
|
|
if (asset is not None and asset.poster_path)
|
|
else None,
|
|
"profile_id": job.profile_id,
|
|
"spec": job.profile_snapshot,
|
|
"display_name": job.display_name,
|
|
"display_uploader": job.display_uploader,
|
|
"display_uploader_url": job.display_uploader_url,
|
|
"extra_links": job.extra_links or [],
|
|
"job_kind": job.job_kind,
|
|
"source_job_id": job.source_job_id,
|
|
"edit_spec": job.edit_spec,
|
|
"can_download": ready and job.status == "done",
|
|
"expired": job.asset_id is None and job.status == "done",
|
|
"asset": None
|
|
if asset is None
|
|
else {
|
|
"id": asset.id,
|
|
"status": asset.status,
|
|
"title": asset.title,
|
|
"uploader": asset.uploader,
|
|
"upload_date": iso(asset.upload_date),
|
|
"duration_s": asset.duration_s,
|
|
"size_bytes": asset.size_bytes,
|
|
"width": asset.width,
|
|
"height": asset.height,
|
|
"container": asset.container,
|
|
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
|
|
# generic uploaded file is download-only. Null while still in flight (set at finalize).
|
|
"media_kind": asset.media_kind,
|
|
"uploader_url": asset.uploader_url,
|
|
"thumbnail_url": asset.thumbnail_url,
|
|
"expires_at": iso(asset.expires_at),
|
|
},
|
|
}
|
|
|
|
|
|
def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
|
|
ids = {j.asset_id for j in jobs if j.asset_id}
|
|
if not ids:
|
|
return {}
|
|
rows = db.execute(select(MediaAsset).where(MediaAsset.id.in_(ids))).scalars()
|
|
return {a.id: a for a in rows}
|
|
|
|
|
|
def _serialize_job(db: Session, job: DownloadJob) -> dict:
|
|
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
|
|
every single-job handler shares."""
|
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
return _serialize(job, asset)
|
|
|
|
|
|
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
"""The ownership check (`owned_or_404`) for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
|
or writes the status those decide on (pause). The job is re-read under a row lock, so two
|
|
overlapping requests for one job run one after the other and the second decides on the first's
|
|
committed result — never both releasing the same hold, and never a pause turning a job a cancel
|
|
just released back into a holder; either can free a file another job still holds.
|
|
|
|
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
|
(`service.finalize_if_running`), so the two can never deadlock."""
|
|
while True:
|
|
asset_id = owned_or_404(db, DownloadJob, job_id, user, what="download").asset_id
|
|
if asset_id is not None:
|
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
|
job = db.execute(
|
|
select(DownloadJob)
|
|
.where(DownloadJob.id == job_id)
|
|
.with_for_update()
|
|
.execution_options(populate_existing=True)
|
|
).scalar_one_or_none()
|
|
if job is None:
|
|
raise not_found("download")
|
|
if job.asset_id == asset_id:
|
|
return job
|
|
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
|
|
|
|
|
# --- profiles ------------------------------------------------------------------------------
|
|
|
|
|
|
def _serialize_profile(p: DownloadProfile) -> dict:
|
|
return {
|
|
"id": p.id,
|
|
"name": p.name,
|
|
"spec": p.spec,
|
|
"is_builtin": p.is_builtin,
|
|
"sort_order": p.sort_order,
|
|
}
|
|
|
|
|
|
@router.get("/profiles")
|
|
def list_profiles(user: User = Depends(require_human), db: Session = Depends(get_db)) -> list[dict]:
|
|
rows = (
|
|
db.execute(
|
|
select(DownloadProfile)
|
|
.where((DownloadProfile.is_builtin.is_(True)) | (DownloadProfile.user_id == user.id))
|
|
.order_by(
|
|
DownloadProfile.is_builtin.desc(), DownloadProfile.sort_order, DownloadProfile.id
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [_serialize_profile(p) for p in rows]
|
|
|
|
|
|
class CreateProfileIn(BaseModel):
|
|
name: str
|
|
spec: dict
|
|
|
|
|
|
@router.post("/profiles")
|
|
def create_profile(
|
|
body: CreateProfileIn, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
name = body.name.strip()
|
|
spec = body.spec
|
|
if not name:
|
|
raise refused(400, "profile_name_required")
|
|
maxpos = db.execute(
|
|
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
|
DownloadProfile.user_id == user.id
|
|
)
|
|
).scalar_one()
|
|
p = DownloadProfile(
|
|
user_id=user.id, name=name[:80], spec=spec, is_builtin=False, sort_order=maxpos + 1
|
|
)
|
|
db.add(p)
|
|
db.commit()
|
|
return _serialize_profile(p)
|
|
|
|
|
|
class UpdateProfileIn(BaseModel):
|
|
name: str | None = None
|
|
spec: dict | None = None
|
|
|
|
|
|
@router.patch("/profiles/{profile_id}")
|
|
def update_profile(
|
|
profile_id: int,
|
|
body: UpdateProfileIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
p = db.get(DownloadProfile, profile_id)
|
|
if p is None or p.is_builtin or p.user_id != user.id:
|
|
raise not_found("profile")
|
|
sent = body.model_fields_set
|
|
if "name" in sent:
|
|
name = (body.name or "").strip()
|
|
if not name:
|
|
raise refused(400, "profile_name_required")
|
|
p.name = name[:80]
|
|
if "spec" in sent:
|
|
if body.spec is None:
|
|
raise refused(400, "spec_not_object")
|
|
p.spec = body.spec
|
|
db.commit()
|
|
return _serialize_profile(p)
|
|
|
|
|
|
@router.delete("/profiles/{profile_id}")
|
|
def delete_profile(
|
|
profile_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
p = db.get(DownloadProfile, profile_id)
|
|
if p is None or p.is_builtin or p.user_id != user.id:
|
|
raise not_found("profile")
|
|
db.delete(p)
|
|
db.commit()
|
|
return {"deleted": profile_id}
|
|
|
|
|
|
# --- per-user download auth (cookies) ------------------------------------------------------
|
|
# Write-only surface: the stored blob is NEVER returned — only the supported hosts + whether each
|
|
# is configured + when. See downloads.credentials for the host-scope + cache-privacy guarantees.
|
|
|
|
|
|
@router.get("/credentials")
|
|
def list_credentials(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
|
return {"hosts": dlcreds.status_list(db, user.id)}
|
|
|
|
|
|
class SetCredentialIn(BaseModel):
|
|
cookies: str
|
|
|
|
|
|
@router.put("/credentials/{host_key}")
|
|
def set_credential(
|
|
host_key: str,
|
|
body: SetCredentialIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
|
|
never echoed back, logged, or audited — only the fact that it changed."""
|
|
if not dlcreds.is_supported_host(host_key):
|
|
raise not_found("host")
|
|
try:
|
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
|
except dlcreds.InvalidCookies as e:
|
|
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)})
|
|
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
|
|
audit.record(
|
|
db,
|
|
user.id,
|
|
audit.AuditAction.DOWNLOAD_CREDENTIAL_SET,
|
|
target_type="download_credential",
|
|
target_id=host_key,
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "hosts": dlcreds.status_list(db, user.id)}
|
|
|
|
|
|
@router.delete("/credentials/{host_key}")
|
|
def delete_credential(
|
|
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
if not dlcreds.is_supported_host(host_key):
|
|
raise not_found("host")
|
|
if dlcreds.delete_credential(db, user.id, host_key):
|
|
audit.record(
|
|
db,
|
|
user.id,
|
|
audit.AuditAction.DOWNLOAD_CREDENTIAL_DELETE,
|
|
target_type="download_credential",
|
|
target_id=host_key,
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "hosts": dlcreds.status_list(db, user.id)}
|
|
|
|
|
|
def _resolve_spec(
|
|
db: Session, user: User, profile_id: int | None, spec: dict | None
|
|
) -> tuple[dict, int | None]:
|
|
"""Pick the format spec for an enqueue: an explicit profile_id (builtin or the user's own),
|
|
an inline spec, or the first builtin as a fallback."""
|
|
if profile_id is not None:
|
|
p = db.get(DownloadProfile, profile_id)
|
|
if p is None or (not p.is_builtin and p.user_id != user.id):
|
|
raise not_found("profile")
|
|
return p.spec, p.id
|
|
if spec is not None:
|
|
return spec, None
|
|
fallback = db.execute(
|
|
select(DownloadProfile)
|
|
.where(DownloadProfile.is_builtin.is_(True))
|
|
.order_by(DownloadProfile.sort_order)
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if fallback is None:
|
|
raise refused(400, "no_profile")
|
|
return fallback.spec, fallback.id
|
|
|
|
|
|
# --- enqueue + list + manage ---------------------------------------------------------------
|
|
|
|
|
|
class EnqueueDownloadIn(BaseModel):
|
|
source: str = ""
|
|
profile_id: int | None = None
|
|
spec: dict | None = None
|
|
display_name: str | None = None
|
|
|
|
|
|
@router.post("")
|
|
def enqueue_download(
|
|
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
source_kind, source_ref = resolve_source(body.source)
|
|
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
|
|
# ones pointing back into our own network BEFORE a job exists. The worker re-checks (DNS can
|
|
# move between here and the download); see downloads/urlguard.py for what that does and does
|
|
# not cover.
|
|
if source_kind == "url":
|
|
try:
|
|
urlguard.check_url(source_ref)
|
|
except urlguard.BlockedUrl as e:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
|
|
) from e
|
|
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
|
|
display_name = display_text(body.display_name)
|
|
try:
|
|
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
|
except quota.QuotaExceeded as e:
|
|
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
|
return _serialize_job(db, job)
|
|
|
|
|
|
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
|
|
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
|
|
# FastAPI *before the first dependency runs*, so `require_human` could not gate it and an anonymous
|
|
# caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20
|
|
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
|
|
# declared AFTER the auth one, is the only shape that closes it — the same one
|
|
# `POST /api/debug-sessions/{sid}/screenshots` uses, and `test_body_limit.py` now enforces it for
|
|
# every route with a raised ceiling rather than leaving it to a comment like this one.
|
|
#
|
|
# Headers are latin-1 on the wire, so both are percent-encoded by the client and unquoted here; a
|
|
# Hungarian filename would otherwise be unsendable.
|
|
UPLOAD_FILENAME_HEADER = "x-upload-filename"
|
|
UPLOAD_DISPLAY_NAME_HEADER = "x-upload-display-name"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SpooledUpload:
|
|
"""What the body dependency hands the handler: the bytes already on disk, plus the two names
|
|
that came alongside them."""
|
|
|
|
path: Path
|
|
size: int
|
|
user_id: int
|
|
filename: str
|
|
display_name: str | None
|
|
|
|
|
|
def _header_text(request: Request, name: str) -> str:
|
|
"""One percent-encoded header, decoded. A header that is not valid UTF-8 once unquoted is the
|
|
caller's doing, not a server fault — it degrades to empty rather than 500ing, and the route's
|
|
own fallback names the file."""
|
|
raw = request.headers.get(name, "")
|
|
try:
|
|
return unquote(raw, errors="strict").strip()
|
|
except (UnicodeDecodeError, ValueError):
|
|
# silent-ok: a mis-encoded header is the caller's own malformed request, and this one is
|
|
# cosmetic — it names the file. Refusing the upload over it would fail a 500 MiB transfer on
|
|
# a label; logging it would hand any client a free log line per request.
|
|
return ""
|
|
|
|
|
|
async def spool_upload(
|
|
request: Request,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> AsyncIterator[SpooledUpload]:
|
|
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
|
|
|
|
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
|
|
naming `require_human` here makes the auth check a precondition of reading the body at all — a
|
|
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
|
|
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
|
|
empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
|
|
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
|
user_id = user.id
|
|
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
|
|
# for why, and for the precondition it enforces. Everything this dependency still needs is
|
|
# already an int or a header in hand: touching an ORM attribute below would reload `user` and
|
|
# check a connection straight back out.
|
|
release_read_connection(db)
|
|
staging = Path(settings.download_root) / ".staging" / "uploads"
|
|
staging.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
|
tmp_path = Path(tmp)
|
|
try:
|
|
with os.fdopen(fd, "wb") as out:
|
|
size = await capped_stream_to(
|
|
request,
|
|
out,
|
|
cap,
|
|
HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
|
|
)
|
|
yield SpooledUpload(
|
|
path=tmp_path,
|
|
size=size,
|
|
user_id=user_id,
|
|
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
|
|
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
|
|
)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
@router.post("/upload")
|
|
def upload_download(
|
|
upload: SpooledUpload = Depends(spool_upload),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
|
|
(video/audio) gets the full function set, a generic file is download-only. The body IS the file
|
|
(see the header note above); `spool_upload` has already authenticated the caller and streamed it
|
|
to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
|
|
probes + places it and returns an already-`done` job.
|
|
|
|
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
|
plus sync DB writes — so it must NOT run on the event loop. The streaming half is async because
|
|
reading the body is; keeping them in separate callables is what lets each run where it belongs."""
|
|
if upload.size == 0:
|
|
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
|
try:
|
|
job = uploadmod.ingest_upload(
|
|
db,
|
|
upload.user_id,
|
|
filename=upload.filename,
|
|
src_path=upload.path,
|
|
size=upload.size,
|
|
display_name=upload.display_name,
|
|
)
|
|
except quota.QuotaExceeded as e:
|
|
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
|
return _serialize_job(db, job)
|
|
|
|
|
|
class EnqueueEditIn(BaseModel):
|
|
source_job_id: int = 0
|
|
edit_spec: dict
|
|
display_name: str | None = None
|
|
|
|
|
|
@router.post("/edit")
|
|
def enqueue_edit(
|
|
body: EnqueueEditIn, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""Queue a phase-2 editor derivative (trim/crop) of a download this user can access.
|
|
|
|
The source may be the user's own download OR one shared with them — either way the resulting
|
|
clip is a NEW per-user derivative in the editor's own library (counts against their quota); the
|
|
source file is only read, never modified, so editing a shared video is safe."""
|
|
source_job = _accessible_job(db, user, body.source_job_id)
|
|
if source_job.status != "done" or source_job.asset_id is None:
|
|
raise refused(409, "edit_unfinished")
|
|
spec = body.edit_spec
|
|
display_name = display_text(body.display_name)
|
|
try:
|
|
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
|
except quota.QuotaExceeded as e:
|
|
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
|
except service.EditError as e:
|
|
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason})
|
|
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()."""
|
|
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
|
|
|
|
|
|
@router.get("")
|
|
def list_downloads(
|
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
jobs = (
|
|
db.execute(
|
|
select(DownloadJob)
|
|
.where(DownloadJob.user_id == user.id)
|
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
|
.limit(_MAX_LIST)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assets = _assets_for(db, jobs)
|
|
return [_serialize(j, assets.get(j.asset_id)) for j in jobs]
|
|
|
|
|
|
@router.get("/usage")
|
|
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
|
"""The caller's download quota, plus the per-file upload cap.
|
|
|
|
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
|
|
`sysconfig` knob about one route — and this is the route layer, where composing the two is the
|
|
job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side
|
|
caps answer too late to be pleasant (the edge ceiling refuses the request outright, see
|
|
`bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the
|
|
limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the
|
|
boundary: the caps stay where they are and still refuse a client that ignores this.
|
|
"""
|
|
return {
|
|
**quota.usage(db, user.id),
|
|
"upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"),
|
|
}
|
|
|
|
|
|
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
|
_INDEX_RANK = {"done": 4, "running": 3, "paused": 2, "queued": 1}
|
|
|
|
|
|
@router.get("/index")
|
|
def my_download_index(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
|
"""source_ref -> best active status, for the feed's per-card "downloaded / queued" badge.
|
|
Small + cheap so the feed can poll it without loading the full job list."""
|
|
rows = db.execute(
|
|
select(DownloadJob.source_ref, DownloadJob.status).where(
|
|
DownloadJob.user_id == user.id,
|
|
DownloadJob.status.in_(("queued", "running", "paused", "done")),
|
|
)
|
|
).all()
|
|
out: dict[str, str] = {}
|
|
for ref, status in rows:
|
|
if _INDEX_RANK.get(status, 0) > _INDEX_RANK.get(out.get(ref, ""), 0):
|
|
out[ref] = status
|
|
return out
|
|
|
|
|
|
@router.get("/shared")
|
|
def shared_with_me(
|
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
jobs = (
|
|
db.execute(
|
|
select(DownloadJob)
|
|
.join(DownloadShare, DownloadShare.job_id == DownloadJob.id)
|
|
.where(DownloadShare.to_user_id == user.id)
|
|
.order_by(DownloadShare.created_at.desc(), DownloadJob.id.desc())
|
|
.limit(_MAX_LIST)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assets = _assets_for(db, jobs)
|
|
out = []
|
|
for j in jobs:
|
|
data = _serialize(j, assets.get(j.asset_id))
|
|
data["shared"] = True
|
|
out.append(data)
|
|
return out
|
|
|
|
|
|
_MAX_EXTRA_LINKS = 8
|
|
|
|
|
|
def _clean_url(raw) -> str | None:
|
|
"""Accept only a plausible http(s) URL (these get rendered as clickable links + embedded in a
|
|
public watch page); anything else is dropped rather than trusted."""
|
|
if not isinstance(raw, str):
|
|
return None
|
|
url = raw.strip()
|
|
if not url:
|
|
return None
|
|
if not url.startswith(("http://", "https://")):
|
|
return None
|
|
# The third site in this module handing user input to `urlparse`, which raises on a malformed
|
|
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
|
|
try:
|
|
parsed = urlparse(url)
|
|
# silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL"
|
|
# answer, the caller already has one, and any line here would carry the URL itself.
|
|
except ValueError:
|
|
return None
|
|
if not parsed.netloc:
|
|
return None
|
|
return url[:2048]
|
|
|
|
|
|
class UpdateDownloadMetaIn(BaseModel):
|
|
# PATCH display-only overrides; presence (model_fields_set) decides what's touched. A field
|
|
# set to null/"" clears the override (falls back to the on-disk system value).
|
|
display_name: str | None = None
|
|
display_uploader: str | None = None
|
|
display_uploader_url: str | None = None
|
|
extra_links: list | None = None
|
|
|
|
|
|
@router.patch("/{job_id}")
|
|
def update_download_meta(
|
|
job_id: int,
|
|
body: UpdateDownloadMetaIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
|
|
All are display-only overrides — the on-disk file keeps its system-decided, id-bound name."""
|
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
sent = body.model_fields_set
|
|
if "display_name" in sent:
|
|
job.display_name = display_text(body.display_name)
|
|
if "display_uploader" in sent:
|
|
job.display_uploader = display_text(body.display_uploader)
|
|
if "display_uploader_url" in sent:
|
|
job.display_uploader_url = _clean_url(body.display_uploader_url)
|
|
if "extra_links" in sent:
|
|
raw = body.extra_links or []
|
|
cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS]
|
|
job.extra_links = cleaned or None
|
|
db.commit()
|
|
return _serialize_job(db, job)
|
|
|
|
|
|
@router.post("/{job_id}/pause")
|
|
def pause_download(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
job = _own_job_for_update(db, user, job_id)
|
|
service.pause(db, job)
|
|
return _serialize_job(db, job)
|
|
|
|
|
|
@router.post("/{job_id}/resume")
|
|
def resume_download(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
job = _own_job_for_update(db, user, job_id)
|
|
service.resume(db, job)
|
|
return _serialize_job(db, job)
|
|
|
|
|
|
@router.post("/{job_id}/redownload")
|
|
def redownload_download(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""Force a fresh re-download of a finished (or failed) job, rebuilding the file + Plex tree.
|
|
|
|
The SAME job row is kept, so any public share link (DownloadLink → job_id) keeps working and
|
|
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
|
|
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
|
|
|
|
The rebuild always re-fetches into a FRESH, private asset (the cache sig is salted with the new
|
|
asset's own id, and the user's cookie salt when one applies — `credentials.rebuild_salt`) rather
|
|
than the dedup pool. That means it (a) always genuinely rebuilds
|
|
under the current format/naming rules — never silently reuses a shared cached file — and (b)
|
|
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.
|
|
raise refused(409, "not_redownloadable")
|
|
if job.status in ("queued", "running", "paused"):
|
|
raise refused(409, "still_downloading")
|
|
service.redownload(db, job)
|
|
return _serialize_job(db, job)
|
|
|
|
|
|
@router.post("/{job_id}/cancel")
|
|
def cancel_download(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
job = _own_job_for_update(db, user, job_id)
|
|
service.cancel(db, job)
|
|
return _serialize_job(db, job)
|
|
|
|
|
|
@router.delete("/{job_id}")
|
|
def delete_download(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
job = _own_job_for_update(db, user, job_id)
|
|
service.delete(db, job)
|
|
return {"deleted": job_id}
|
|
|
|
|
|
# --- file download (range-aware, custom display name) --------------------------------------
|
|
|
|
|
|
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
job = db.get(DownloadJob, job_id)
|
|
if job is None:
|
|
raise not_found("download")
|
|
if job.user_id == user.id:
|
|
return job
|
|
shared = db.execute(
|
|
select(DownloadShare.id).where(
|
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
|
)
|
|
).first()
|
|
if shared is None:
|
|
raise not_found("download")
|
|
return job
|
|
|
|
|
|
@router.get("/{job_id}/file")
|
|
def download_file(
|
|
job_id: int,
|
|
request: Request,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
job = _accessible_job(db, user, job_id)
|
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
if asset is None or asset.status != "ready" or not asset.rel_path:
|
|
raise refused(409, "download_not_ready")
|
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
|
if path is None:
|
|
raise not_found("file")
|
|
|
|
filename = storage.download_filename(
|
|
job.display_name or asset.title or asset.source_ref, asset.container, path
|
|
)
|
|
|
|
asset.last_access_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
# The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
|
|
# (storage.served_media_type), NEVER guessed from the file's own extension. A generic 'file' (and
|
|
# any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
|
|
# returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
|
|
# this origin. Recognised media is served with its real type so the in-app player streams it. Both
|
|
# are range-aware (FileResponse → 206).
|
|
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
|
if media_type is None:
|
|
return FileResponse(
|
|
path,
|
|
filename=filename,
|
|
media_type="application/octet-stream",
|
|
content_disposition_type="attachment",
|
|
)
|
|
return FileResponse(path, filename=filename, media_type=media_type)
|
|
|
|
|
|
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
|
|
|
|
|
@router.get("/{job_id}/storyboard")
|
|
def storyboard_meta(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""Filmstrip geometry for the editor's scrub track, generating the sprite on first use.
|
|
|
|
Best-effort: for a very long/high-res source the sprite may not generate in time — then
|
|
`available` is False and the editor shows a plain timeline."""
|
|
job = _accessible_job(db, user, job_id)
|
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
if asset is None or asset.status != "ready" or not asset.rel_path:
|
|
raise refused(409, "download_not_ready")
|
|
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
|
if meta is None:
|
|
return {"available": False}
|
|
db.commit() # persist storyboard_path if it was just generated
|
|
return {
|
|
"available": True,
|
|
"cols": meta["cols"],
|
|
"rows": meta["rows"],
|
|
"count": meta["count"],
|
|
"interval_s": meta["interval_s"],
|
|
"url": f"/api/downloads/{job_id}/storyboard.jpg",
|
|
}
|
|
|
|
|
|
@router.get("/{job_id}/poster.jpg")
|
|
def poster_image(job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)):
|
|
"""The locally-generated poster frame for a download whose source had no thumbnail."""
|
|
job = _accessible_job(db, user, job_id)
|
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
if asset is None or not asset.poster_path:
|
|
raise not_found("poster")
|
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
|
if path is None:
|
|
raise not_found("poster")
|
|
return FileResponse(path, media_type="image/jpeg")
|
|
|
|
|
|
@router.get("/{job_id}/storyboard.jpg")
|
|
def storyboard_image(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
):
|
|
job = _accessible_job(db, user, job_id)
|
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
if asset is None or not asset.storyboard_path:
|
|
raise not_found("filmstrip")
|
|
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
|
if path is None:
|
|
raise not_found("filmstrip")
|
|
return FileResponse(path, media_type="image/jpeg")
|
|
|
|
|
|
# --- sharing -------------------------------------------------------------------------------
|
|
|
|
|
|
class ShareDownloadIn(BaseModel):
|
|
email: str = ""
|
|
|
|
|
|
@router.post("/{job_id}/share")
|
|
def share_download(
|
|
job_id: int,
|
|
body: ShareDownloadIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
if job.status != "done" or job.asset_id is None:
|
|
raise refused(409, "share_unfinished")
|
|
target = body.email.strip().lower()
|
|
if not target:
|
|
raise refused(400, "recipient_required")
|
|
recipient = db.execute(
|
|
select(User).where(func.lower(User.email) == target)
|
|
).scalar_one_or_none()
|
|
# Uniform "no user" for a missing OR non-messageable (demo/suspended/deactivated) target, so
|
|
# sharing can't be used to probe which addresses exist or to reach a suspended account.
|
|
if not is_messageable_user(recipient):
|
|
raise not_found("recipient")
|
|
if recipient.id == user.id:
|
|
raise refused(400, "share_own")
|
|
exists = db.execute(
|
|
select(DownloadShare.id).where(
|
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
|
)
|
|
).first()
|
|
if exists is None:
|
|
db.add(DownloadShare(job_id=job_id, from_user_id=user.id, to_user_id=recipient.id))
|
|
from app.notifications import create_notification
|
|
|
|
create_notification(
|
|
db,
|
|
user_id=recipient.id,
|
|
type="download_shared",
|
|
title=(job.display_name or "A download") + " was shared with you",
|
|
# The client names the notice in the reader's language from `data` (E30); the title is the
|
|
# English fallback only.
|
|
data={
|
|
"kind": "download_shared",
|
|
"from": user.email,
|
|
"job_id": job_id,
|
|
"title": job.display_name,
|
|
},
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "shared_with": recipient.email}
|
|
|
|
|
|
@router.delete("/{job_id}/share/{email}")
|
|
def unshare_download(
|
|
job_id: int,
|
|
email: str,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
owned_or_404(
|
|
db, DownloadJob, job_id, user, what="download"
|
|
) # ownership guard (404s if not the caller's job)
|
|
recipient = db.execute(
|
|
select(User).where(func.lower(User.email) == email.strip().lower())
|
|
).scalar_one_or_none()
|
|
if recipient is not None:
|
|
row = db.execute(
|
|
select(DownloadShare).where(
|
|
DownloadShare.job_id == job_id,
|
|
DownloadShare.to_user_id == recipient.id,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is not None:
|
|
db.delete(row)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/shared/{job_id}")
|
|
def remove_shared_with_me(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""Remove a download that was shared WITH this user from their 'Shared with me' list. Deletes
|
|
only the recipient's share grant — the owner's job and the physical file are untouched (this is
|
|
a per-user dismissal, not a delete)."""
|
|
row = db.execute(
|
|
select(DownloadShare).where(
|
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is not None:
|
|
db.delete(row)
|
|
db.commit()
|
|
return {"ok": True, "removed": job_id}
|
|
|
|
|
|
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
|
|
|
|
|
@router.get("/recipients")
|
|
def share_recipients(
|
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
"""Registered users this user can share a download with (excludes self + any non-messageable
|
|
account — demo, suspended, or deactivated — so the picker never leaks those addresses)."""
|
|
rows = (
|
|
db.execute(
|
|
select(User)
|
|
.where(User.id != user.id, *messageable_clauses())
|
|
.order_by(func.lower(User.email))
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [{"id": u.id, "email": u.email, "display_name": u.display_name} for u in rows]
|
|
|
|
|
|
# --- public watch links (share by link) ----------------------------------------------------
|
|
|
|
|
|
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
|
|
link = db.get(DownloadLink, link_id)
|
|
if link is None:
|
|
raise not_found("link")
|
|
job = db.get(DownloadJob, link.job_id)
|
|
if job is None or job.user_id != user.id:
|
|
raise not_found("link")
|
|
return link
|
|
|
|
|
|
def _link_expiry(days: int | None) -> datetime | None:
|
|
# None/0/negative → no expiry; otherwise N days out, capped at ~10 years. The Pydantic model
|
|
# already coerced/validated the type, so a non-number is a 422 before we get here.
|
|
if not days or days <= 0:
|
|
return None
|
|
return datetime.now(timezone.utc) + timedelta(days=min(days, 3650))
|
|
|
|
|
|
@router.get("/{job_id}/links")
|
|
def list_links(
|
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
rows = (
|
|
db.execute(
|
|
select(DownloadLink)
|
|
.where(DownloadLink.job_id == job_id)
|
|
.order_by(DownloadLink.id.desc())
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [linksmod.owner_view(row) for row in rows]
|
|
|
|
|
|
class CreateLinkIn(BaseModel):
|
|
password: str = ""
|
|
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
|
|
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
|
|
# minted from now on; existing links keep whatever `allow_download` they were created with (the
|
|
# column default stays False, so no back-catalogue link silently flips). The owner can still
|
|
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
|
|
allow_download: bool = True
|
|
expires_days: int | None = None
|
|
|
|
|
|
@router.post("/{job_id}/links")
|
|
def create_link(
|
|
job_id: int,
|
|
body: CreateLinkIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
if job.status != "done" or job.asset_id is None:
|
|
raise refused(409, "share_unfinished")
|
|
pw = body.password.strip()
|
|
link = DownloadLink(
|
|
job_id=job_id,
|
|
token=linksmod.new_token(),
|
|
created_by=user.id,
|
|
allow_download=body.allow_download,
|
|
password_hash=hash_password(pw) if pw else None,
|
|
expires_at=_link_expiry(body.expires_days),
|
|
)
|
|
db.add(link)
|
|
db.commit()
|
|
db.refresh(link)
|
|
return linksmod.owner_view(link)
|
|
|
|
|
|
class UpdateLinkIn(BaseModel):
|
|
allow_download: bool | None = None
|
|
expires_days: int | None = None
|
|
password: str | None = None
|
|
|
|
|
|
@router.patch("/links/{link_id}")
|
|
def update_link(
|
|
link_id: int,
|
|
body: UpdateLinkIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
link = _own_link(db, user, link_id)
|
|
sent = body.model_fields_set
|
|
if "allow_download" in sent:
|
|
link.allow_download = bool(body.allow_download)
|
|
if "expires_days" in sent:
|
|
link.expires_at = _link_expiry(body.expires_days)
|
|
if "password" in sent:
|
|
pw = (body.password or "").strip()
|
|
link.password_hash = hash_password(pw) if pw else None
|
|
# Bump the version so any grant issued under the previous password stops working now, rather
|
|
# than living out its 6h TTL (setting/clearing/changing the password all count as a rotation).
|
|
link.password_version += 1
|
|
db.commit()
|
|
return linksmod.owner_view(link)
|
|
|
|
|
|
@router.delete("/links/{link_id}")
|
|
def revoke_link(
|
|
link_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
link = _own_link(db, user, link_id)
|
|
db.delete(link)
|
|
db.commit()
|
|
return {"deleted": link_id}
|
|
|
|
|
|
# --- admin ---------------------------------------------------------------------------------
|
|
|
|
|
|
@admin_router.get("")
|
|
def admin_list_downloads(
|
|
admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
jobs = (
|
|
db.execute(
|
|
select(DownloadJob)
|
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
|
.limit(_MAX_LIST)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assets = _assets_for(db, jobs)
|
|
emails = {
|
|
u.id: u.email
|
|
for u in db.execute(select(User).where(User.id.in_({j.user_id for j in jobs}))).scalars()
|
|
}
|
|
out = []
|
|
for j in jobs:
|
|
data = _serialize(j, assets.get(j.asset_id))
|
|
data["user_email"] = emails.get(j.user_id)
|
|
out.append(data)
|
|
return out
|
|
|
|
|
|
@admin_router.get("/storage")
|
|
def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
ready = db.execute(
|
|
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
|
MediaAsset.status == "ready"
|
|
)
|
|
).one()
|
|
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
|
per_user = db.execute(
|
|
select(DownloadJob.user_id, func.count(func.distinct(DownloadJob.asset_id)))
|
|
.where(DownloadJob.asset_id.is_not(None))
|
|
.group_by(DownloadJob.user_id)
|
|
).all()
|
|
emails = {
|
|
u.id: u.email
|
|
for u in db.execute(select(User).where(User.id.in_([uid for uid, _ in per_user]))).scalars()
|
|
}
|
|
return {
|
|
"ready_files": ready[0],
|
|
"total_bytes": int(ready[1]),
|
|
"total_assets": total_assets,
|
|
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
|
"per_user": [
|
|
{"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)}
|
|
for uid, _ in per_user
|
|
],
|
|
}
|
|
|
|
|
|
@admin_router.get("/quota/{user_id}")
|
|
def admin_get_quota(
|
|
user_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
lim = quota.resolve(db, user_id)
|
|
row = db.get(DownloadQuota, user_id)
|
|
return {
|
|
"user_id": user_id,
|
|
"custom": row is not None,
|
|
"max_bytes": lim.max_bytes,
|
|
"max_concurrent": lim.max_concurrent,
|
|
"max_jobs": lim.max_jobs,
|
|
"unlimited": lim.unlimited,
|
|
}
|
|
|
|
|
|
class AdminSetQuotaIn(BaseModel):
|
|
# Each field is set only when provided as a real value (is not None). A non-numeric limit
|
|
# used to reach int() and 500; it's now a clean 422.
|
|
max_bytes: int | None = None
|
|
max_concurrent: int | None = None
|
|
max_jobs: int | None = None
|
|
unlimited: bool | None = None
|
|
|
|
|
|
@admin_router.put("/quota/{user_id}")
|
|
def admin_set_quota(
|
|
user_id: int,
|
|
body: AdminSetQuotaIn,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
get_or_404(db, User, user_id, what="user")
|
|
cur = quota.resolve(db, user_id)
|
|
row = db.get(DownloadQuota, user_id)
|
|
if row is None:
|
|
row = DownloadQuota(
|
|
user_id=user_id,
|
|
max_bytes=cur.max_bytes,
|
|
max_concurrent=cur.max_concurrent,
|
|
max_jobs=cur.max_jobs,
|
|
unlimited=cur.unlimited,
|
|
)
|
|
db.add(row)
|
|
if body.max_bytes is not None:
|
|
row.max_bytes = max(0, body.max_bytes)
|
|
if body.max_concurrent is not None:
|
|
row.max_concurrent = max(1, body.max_concurrent)
|
|
if body.max_jobs is not None:
|
|
row.max_jobs = max(1, body.max_jobs)
|
|
if body.unlimited is not None:
|
|
row.unlimited = body.unlimited
|
|
db.commit()
|
|
return admin_get_quota(user_id, admin, db)
|
|
|
|
|
|
@admin_router.delete("/quota/{user_id}")
|
|
def admin_reset_quota(
|
|
user_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
row = db.get(DownloadQuota, user_id)
|
|
if row is not None:
|
|
db.delete(row)
|
|
db.commit()
|
|
return admin_get_quota(user_id, admin, db)
|