The nine Capture tables (files, jobs, folders, shares, links, quota, cookies, profiles, library resume points) leave app/models.py for the unit's own module; app.tables registers every unit's tables for the migrations, the test database and the ownership check. No schema change.
1066 lines
38 KiB
Python
1066 lines
38 KiB
Python
"""Download center HTTP API — the per-user surface over the worker/cache/quota layers.
|
|
|
|
All routes are behind `current_user`; 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, and shares finished files. The library itself — listing, renaming, moving, deleting and
|
|
serving a done file, and uploading one — is `routes.files`.
|
|
"""
|
|
|
|
import re
|
|
from typing import Literal
|
|
from datetime import datetime, timedelta, timezone
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
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
|
|
|
|
from app.mailtext import lang_for_user
|
|
from app.collation import collated
|
|
from app import audit, sysconfig
|
|
from app.auth import admin_user, current_user
|
|
from app.config import settings
|
|
from app.db import get_db
|
|
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 folders
|
|
from app.downloads import links as linksmod
|
|
from app.downloads import quota, service, storage
|
|
from app.downloads import urlguard
|
|
from app.downloads.folders import FolderKind
|
|
from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus
|
|
from app.models import User, display_text
|
|
from app.downloads.models import (
|
|
DownloadJob,
|
|
DownloadLink,
|
|
FileFolder,
|
|
DownloadProfile,
|
|
DownloadQuota,
|
|
DownloadShare,
|
|
LibraryState,
|
|
MediaAsset,
|
|
)
|
|
from app.routes._common import (
|
|
ProgressIn,
|
|
get_or_404,
|
|
kept_position,
|
|
not_found,
|
|
owned_or_404,
|
|
)
|
|
from app.routes.files import (
|
|
MAX_LIST,
|
|
accessible_job,
|
|
assets_for,
|
|
own_job_for_update,
|
|
positions_for,
|
|
quota_detail,
|
|
serialize,
|
|
serialize_job,
|
|
source_keys,
|
|
)
|
|
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}$")
|
|
|
|
|
|
# --- 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")
|
|
|
|
|
|
# --- 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(current_user), 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(current_user), 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(current_user),
|
|
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(current_user), 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(current_user), 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(current_user),
|
|
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)}) from 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(current_user), 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(current_user), 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)) from 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(current_user), 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 != JobStatus.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)) from e
|
|
except service.EditError as e:
|
|
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
|
|
return serialize_job(db, job)
|
|
|
|
|
|
class ClipCropIn(BaseModel):
|
|
x: float
|
|
y: float
|
|
w: float
|
|
h: float
|
|
aspect: float
|
|
|
|
|
|
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
|
|
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
|
|
crop: ClipCropIn | 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:
|
|
crop = body.crop.model_dump() if body.crop else None
|
|
spec = clipmod.normalize(
|
|
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
|
|
)
|
|
display_name = display_text(body.display_name)
|
|
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
|
except clipmod.ClipError as e:
|
|
status = 404 if e.reason == "not_found" else 422
|
|
detail = {"code": "clip", "reason": e.reason, **e.params}
|
|
raise HTTPException(status_code=status, detail=detail) from e
|
|
except quota.QuotaExceeded as e:
|
|
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
|
return serialize_job(db, job)
|
|
|
|
|
|
# How long a finished job stays in the queue: the tray polls it from every page and must see a job
|
|
# turn done (its "Done · Open" row and the done notice) — the finished file itself is the library's.
|
|
# A done job is timed from when it became done, never from its last write: a rename, a move or the
|
|
# GC's expiry warning touches an old file's row, and the tray would announce it as just finished.
|
|
QUEUE_RECENT = timedelta(minutes=10)
|
|
|
|
|
|
@router.get("")
|
|
def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
|
"""The queue: what is waiting, running, paused or failed, and what finished a moment ago."""
|
|
recent = datetime.now(timezone.utc) - QUEUE_RECENT
|
|
jobs = (
|
|
db.execute(
|
|
select(DownloadJob)
|
|
.where(
|
|
DownloadJob.user_id == user.id,
|
|
DownloadJob.status.not_in(FINISHED)
|
|
| ((DownloadJob.status == JobStatus.DONE) & (DownloadJob.ready_at >= recent))
|
|
| ((DownloadJob.status == JobStatus.CANCELED) & (DownloadJob.updated_at >= recent)),
|
|
)
|
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
|
.limit(MAX_LIST)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assets = assets_for(db, jobs)
|
|
positions = positions_for(db, user, jobs)
|
|
sources = source_keys(db, jobs)
|
|
return [
|
|
{
|
|
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
|
"position_seconds": positions.get(j.id, 0),
|
|
"source_key": sources[j.id],
|
|
}
|
|
for j in jobs
|
|
]
|
|
|
|
|
|
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
|
_INDEX_RANK: dict[str, int] = {
|
|
JobStatus.DONE: 4,
|
|
JobStatus.RUNNING: 3,
|
|
JobStatus.PAUSED: 2,
|
|
JobStatus.QUEUED: 1,
|
|
}
|
|
|
|
|
|
@router.get("/index")
|
|
def my_download_index(user: User = Depends(current_user), 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_(tuple(_INDEX_RANK)),
|
|
)
|
|
).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.post("/{job_id}/pause")
|
|
def pause_download(
|
|
job_id: int, user: User = Depends(current_user), 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(current_user), 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(current_user), 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 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")
|
|
service.redownload(db, job)
|
|
return serialize_job(db, job)
|
|
|
|
|
|
@router.post("/{job_id}/cancel")
|
|
def cancel_download(
|
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
job = own_job_for_update(db, user, job_id)
|
|
service.cancel(db, job)
|
|
return serialize_job(db, job)
|
|
|
|
|
|
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
|
|
|
|
|
@router.post("/{job_id}/progress")
|
|
def set_library_progress(
|
|
job_id: int,
|
|
body: ProgressIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
|
|
one shared with them). A position too early or too close to the end clears it instead."""
|
|
accessible_job(db, user, job_id)
|
|
position = kept_position(body)
|
|
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
|
|
if not position:
|
|
# A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of
|
|
# one stop may both clear it, and the second then deletes nothing rather than failing.
|
|
db.execute(delete(LibraryState).where(mine))
|
|
db.commit()
|
|
return {"ok": True, "job_id": job_id, "position_seconds": 0}
|
|
now = datetime.now(timezone.utc)
|
|
# One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a
|
|
# read-then-insert that the second would fail on the unique key.
|
|
db.execute(
|
|
pg_insert(LibraryState)
|
|
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
|
|
.on_conflict_do_update(
|
|
constraint="uq_library_user_job",
|
|
set_={"position_seconds": position, "progress_updated_at": now},
|
|
)
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "job_id": job_id, "position_seconds": position}
|
|
|
|
|
|
@router.get("/{job_id}/storyboard")
|
|
def storyboard_meta(
|
|
job_id: int, user: User = Depends(current_user), 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.files_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(current_user), 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.files_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(current_user), 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.files_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(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
if job.status != JobStatus.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 (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`; 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(current_user),
|
|
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}
|
|
|
|
|
|
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
|
|
|
|
|
@router.get("/recipients")
|
|
def share_recipients(
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
"""Registered users this user can share a download with (excludes self + any non-messageable
|
|
account — suspended or deactivated — so the picker never leaks those addresses)."""
|
|
rows = (
|
|
db.execute(
|
|
select(User)
|
|
.where(User.id != user.id, *messageable_clauses())
|
|
.order_by(collated(User.email, lang_for_user(user)))
|
|
)
|
|
.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")
|
|
target = (
|
|
db.get(DownloadJob, link.job_id)
|
|
if link.job_id is not None
|
|
else db.get(FileFolder, link.folder_id)
|
|
)
|
|
if target is None or target.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))
|
|
|
|
|
|
def _links_of(db: Session, *where) -> list[dict]:
|
|
rows = db.execute(select(DownloadLink).where(*where).order_by(DownloadLink.id.desc()))
|
|
return [linksmod.owner_view(row) for row in rows.scalars()]
|
|
|
|
|
|
@router.get("/{job_id}/links")
|
|
def list_links(
|
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
return _links_of(db, DownloadLink.job_id == job_id)
|
|
|
|
|
|
class CreateLinkIn(BaseModel):
|
|
password: str = ""
|
|
# 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
|
|
|
|
|
|
def _new_link(
|
|
db: Session, user: User, body: CreateLinkIn, **target: int
|
|
) -> tuple[DownloadLink, str]:
|
|
"""Make a link to `target` (`job_id=` or `folder_id=`, already checked to be the user's), and
|
|
the token it is opened by — which is in hand only now."""
|
|
pw = body.password.strip()
|
|
token = linksmod.new_token()
|
|
link = DownloadLink(
|
|
**target,
|
|
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),
|
|
)
|
|
linksmod.seal(link, token)
|
|
db.add(link)
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
user.id,
|
|
audit.AuditAction.DOWNLOAD_LINK_CREATE,
|
|
target_type="download_link",
|
|
target_id=link.id,
|
|
after={**target, **_link_audit(link)},
|
|
)
|
|
return link, token
|
|
|
|
|
|
@router.post("/{job_id}/links")
|
|
def create_link(
|
|
job_id: int,
|
|
body: CreateLinkIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
job = _shareable_job(db, user, job_id)
|
|
if job.status != JobStatus.DONE or job.asset_id is None:
|
|
raise refused(409, "share_unfinished")
|
|
link, token = _new_link(db, user, body, job_id=job_id)
|
|
db.commit()
|
|
return linksmod.owner_view(link, token=token)
|
|
|
|
|
|
def _shareable_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
"""One of the user's files, held (FOR SHARE) for the rest of the transaction: a delete of it at
|
|
the same moment (the user's, or the retention sweep's) waits and takes the new link with it,
|
|
rather than failing the link's foreign key — or, done first, leaves a 404."""
|
|
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
job = db.execute(
|
|
select(DownloadJob)
|
|
.where(DownloadJob.id == job_id)
|
|
.with_for_update(read=True)
|
|
.execution_options(populate_existing=True)
|
|
).scalar_one_or_none()
|
|
if job is None:
|
|
raise not_found("download")
|
|
return job
|
|
|
|
|
|
def _shareable_folder(db: Session, user: User, folder_id: int) -> FileFolder:
|
|
"""One of the user's own folders. A source folder lets its files go on its own clock, and the Hub
|
|
is the user's channel to a machine of theirs: neither is a shelf to hand out. Held (FOR SHARE)
|
|
for the rest of the transaction, so a delete of it at the same moment waits and takes the new
|
|
link with it, rather than failing the link's foreign key."""
|
|
owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
|
try:
|
|
folder = folders.own_folder(db, user.id, folder_id)
|
|
except folders.FolderError as e:
|
|
raise refused(409, e.reason) from e
|
|
if folder.kind != FolderKind.OWN:
|
|
raise refused(409, "share_folder_kind")
|
|
return folder
|
|
|
|
|
|
@router.get("/folders/{folder_id}/links")
|
|
def list_folder_links(
|
|
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
|
return _links_of(db, DownloadLink.folder_id == folder_id)
|
|
|
|
|
|
@router.post("/folders/{folder_id}/links")
|
|
def create_folder_link(
|
|
folder_id: int,
|
|
body: CreateLinkIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
_shareable_folder(db, user, folder_id)
|
|
link, token = _new_link(db, user, body, folder_id=folder_id)
|
|
db.commit()
|
|
return linksmod.owner_view(link, token=token)
|
|
|
|
|
|
def _link_audit(link: DownloadLink) -> dict:
|
|
"""A link's settings as the audit records them: whether it has a password, never which."""
|
|
return {
|
|
"allow_download": link.allow_download,
|
|
"has_password": link.password_hash is not None,
|
|
"expires_at": iso(link.expires_at),
|
|
}
|
|
|
|
|
|
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(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
link = _own_link(db, user, link_id)
|
|
before = _link_audit(link)
|
|
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
|
|
after = _link_audit(link)
|
|
if after != before or "password" in sent:
|
|
audit.record(
|
|
db,
|
|
user.id,
|
|
audit.AuditAction.DOWNLOAD_LINK_UPDATE,
|
|
target_type="download_link",
|
|
target_id=link_id,
|
|
before=before,
|
|
after=after,
|
|
)
|
|
db.commit()
|
|
return linksmod.owner_view(link)
|
|
|
|
|
|
@router.delete("/links/{link_id}")
|
|
def revoke_link(
|
|
link_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
link = _own_link(db, user, link_id)
|
|
db.delete(link)
|
|
audit.record(
|
|
db,
|
|
user.id,
|
|
audit.AuditAction.DOWNLOAD_LINK_REVOKE,
|
|
target_type="download_link",
|
|
target_id=link_id,
|
|
after={"job_id": link.job_id} if link.job_id is not None else {"folder_id": link.folder_id},
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "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) if j.asset_id is not None else None)
|
|
data["user_id"] = j.user_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:
|
|
# Every file on disk (`quota.ON_DISK`): ready ones, and uploads waiting for or failed in their
|
|
# conversion — the same disk the footprint and the GC cap count.
|
|
on_disk = db.execute(
|
|
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
|
).one()
|
|
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
|
# EVERY user, not only those holding a file: a quota is set before anyone downloads.
|
|
emails: dict[int, str] = {
|
|
uid: email
|
|
for uid, email in db.execute(select(User.id, User.email).order_by(User.email)).tuples()
|
|
}
|
|
uids = list(emails)
|
|
# What each one's Files lists: their finished files.
|
|
files: dict[int, int] = {
|
|
uid: n
|
|
for uid, n in db.execute(
|
|
select(DownloadJob.user_id, func.count())
|
|
.where(DownloadJob.status == JobStatus.DONE)
|
|
.group_by(DownloadJob.user_id)
|
|
).tuples()
|
|
}
|
|
|
|
def row(uid: int) -> dict:
|
|
lim = quota.resolve(db, uid)
|
|
return {
|
|
"user_id": uid,
|
|
"email": emails[uid],
|
|
"footprint_bytes": quota.footprint(db, uid),
|
|
"files": files.get(uid, 0),
|
|
"max_bytes": lim.max_bytes,
|
|
"unlimited": lim.unlimited,
|
|
}
|
|
|
|
return {
|
|
"files_on_disk": on_disk[0],
|
|
"total_bytes": int(on_disk[1]),
|
|
"total_assets": total_assets,
|
|
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
|
"per_user": [row(uid) for uid in uids],
|
|
}
|
|
|
|
|
|
@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)
|