Pause now re-reads the job under the same asset-then-job row lock as resume, cancel, delete and redownload. Its unlocked write waited for a concurrent cancel's lock and then set the released job back to paused, a holding status, so a later delete released the same hold again and could free a file another user's job still held (measured: the overlapping-request test ends 'paused' instead of 'canceled' with the old read). The lock test now calls the route handlers, so it also pins which helper each route uses. Comments follow the code: resolve_url's contract names both verdicts and that a failed page read is raised as itself; the inherited-failure diagnosis asks the cookie gate as it stands when the job is claimed, so its advice fits the cookie that job's Retry would use; the undecryptable-cookie warning no longer claims a download goes ahead, since the worker parent reaches it only to diagnose.
1156 lines
43 KiB
Python
1156 lines
43 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 datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
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
|
|
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,
|
|
)
|
|
from app.routes._common import get_or_404, not_found
|
|
from app.security import hash_password
|
|
from app.userscope import is_messageable_user, messageable_clauses
|
|
from app.utils import iso
|
|
|
|
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 HTTPException(status_code=400, detail="A video link or id is 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 HTTPException(status_code=400, detail="That doesn't look like a valid video 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(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
job = db.get(DownloadJob, job_id)
|
|
if job is None or job.user_id != user.id:
|
|
raise HTTPException(status_code=404, detail="Unknown download")
|
|
return job
|
|
|
|
|
|
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
"""`_own_job` 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 = _own_job(db, user, job_id).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 HTTPException(status_code=404, detail="Unknown 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 HTTPException(status_code=400, detail="A profile name is 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 HTTPException(status_code=404, detail="Unknown profile")
|
|
sent = body.model_fields_set
|
|
if "name" in sent:
|
|
name = (body.name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="A profile name is required.")
|
|
p.name = name[:80]
|
|
if "spec" in sent:
|
|
if body.spec is None:
|
|
raise HTTPException(status_code=400, detail="spec must be an 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 HTTPException(status_code=404, detail="Unknown 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 HTTPException(status_code=404, detail="Unknown 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 HTTPException(status_code=404, detail="Unknown 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 HTTPException(status_code=404, detail="Unknown 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 HTTPException(status_code=400, detail="No download profile available.")
|
|
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 = (body.display_name or "").strip() or None
|
|
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)
|
|
|
|
|
|
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
|
|
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
|
|
_UPLOAD_CHUNK = 1024 * 1024
|
|
|
|
|
|
@router.post("/upload")
|
|
def upload_download(
|
|
file: UploadFile = File(...),
|
|
display_name: str | None = Form(None),
|
|
user: User = Depends(require_human),
|
|
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 file is streamed
|
|
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
|
|
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. `file.file` is the sync file object
|
|
Starlette spools the multipart body into."""
|
|
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
|
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:
|
|
size = 0
|
|
with os.fdopen(fd, "wb") as out:
|
|
while chunk := file.file.read(_UPLOAD_CHUNK):
|
|
size += len(chunk)
|
|
if cap and size > cap:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "upload_too_large", "limit": cap}
|
|
)
|
|
out.write(chunk)
|
|
if size == 0:
|
|
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
|
try:
|
|
job = uploadmod.ingest_upload(
|
|
db,
|
|
user.id,
|
|
filename=file.filename or "upload",
|
|
src_path=tmp_path,
|
|
size=size,
|
|
display_name=(display_name or "").strip() or None,
|
|
)
|
|
except quota.QuotaExceeded as e:
|
|
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
|
return _serialize_job(db, job)
|
|
finally:
|
|
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
|
|
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
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 HTTPException(status_code=409, detail="You can only edit a finished download.")
|
|
spec = body.edit_spec
|
|
display_name = (body.display_name or "").strip() or None
|
|
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:
|
|
return quota.usage(db, user.id)
|
|
|
|
|
|
# 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)
|
|
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 = _own_job(db, user, job_id)
|
|
sent = body.model_fields_set
|
|
if "display_name" in sent:
|
|
job.display_name = ((body.display_name or "").strip())[:255] or None
|
|
if "display_uploader" in sent:
|
|
job.display_uploader = ((body.display_uploader or "").strip())[:255] or None
|
|
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 HTTPException(status_code=409, detail="This item can't be re-downloaded.")
|
|
if job.status in ("queued", "running", "paused"):
|
|
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
|
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 HTTPException(status_code=404, detail="Unknown 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 HTTPException(status_code=404, detail="Unknown 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 HTTPException(status_code=409, detail="This download isn't ready.")
|
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="File is no longer available.")
|
|
|
|
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 HTTPException(status_code=409, detail="This download isn't 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 HTTPException(status_code=404, detail="No poster.")
|
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="No 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 HTTPException(status_code=404, detail="No filmstrip.")
|
|
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="No 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 = _own_job(db, user, job_id)
|
|
if job.status != "done" or job.asset_id is None:
|
|
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
|
|
target = body.email.strip().lower()
|
|
if not target:
|
|
raise HTTPException(status_code=400, detail="A recipient email is 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 HTTPException(status_code=400, detail="That's already your download.")
|
|
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",
|
|
data={"kind": "download_shared", "from": user.email, "job_id": job_id},
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return {"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:
|
|
_own_job(db, user, job_id) # 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 {"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 HTTPException(status_code=404, detail="Unknown link")
|
|
job = db.get(DownloadJob, link.job_id)
|
|
if job is None or job.user_id != user.id:
|
|
raise HTTPException(status_code=404, detail="Unknown 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]:
|
|
_own_job(db, user, job_id)
|
|
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 = _own_job(db, user, job_id)
|
|
if job.status != "done" or job.asset_id is None:
|
|
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
|
|
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)
|