feat(files): the Files page — the library in one aligned list, its folders in a rail

Files is the one library: every done file, the ones shared with me, or one
folder's, picked in a side rail (sources, own folders, the storage meter) and
mirrored to ?folder=. One table with every kind of value in its own column;
Copy link, Share and Save in sight, every action on a right click: play,
cut, rename in the row, move to an own folder or back home, keep, details,
re-download, delete.

The Downloads page keeps only the queue until the tray replaces it. The old
/api/downloads library routes are gone: list shared, upload, the details
PATCH, delete, the file and the usage now live under /api/files, and a
listed file says which source it came from. The job's "expired" flag is gone
with the state it described.
This commit is contained in:
2026-10-11 05:34:25 +02:00
parent 1a1665b8ec
commit 85aa917b38
65 changed files with 2295 additions and 1044 deletions
+1 -2
View File
@@ -92,8 +92,7 @@ ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
# sending it at all, off the `upload_max_bytes` that `GET /api/files/usage` reports.
("POST", "/api/files/upload"): settings.download_upload_max_bytes,
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
}
+3 -1
View File
@@ -690,7 +690,9 @@ _POLLED_ROUTES = frozenset(
"/api/me/notifications/unread_count",
"/api/downloads",
"/api/downloads/index",
"/api/downloads/usage",
"/api/files",
"/api/files/folders",
"/api/files/usage",
"/api/sync/my-status",
"/api/admin/scheduler",
"/api/admin/downloads",
+1 -1
View File
@@ -39,7 +39,7 @@ def display_text(raw: str | None) -> str | None:
blank -> None.
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
`POST /api/downloads/edit`, `POST /api/downloads/upload`, the metadata PATCH's two fields, and
`POST /api/downloads/edit`, `POST /api/files/upload`, the library PATCH's two fields, and
the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather
than refusing: the name is a label on work the user has already done — a file already
transferred, an account already authenticated — and losing its tail is not worth failing the
+2 -156
View File
@@ -2,7 +2,8 @@
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, serves finished files (range-aware, with the user's custom display name), and shares them.
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
@@ -50,18 +51,13 @@ from app.routes._common import (
)
from app.routes.files import (
MAX_LIST,
SpooledUpload,
accessible_job,
assets_for,
delete_file,
own_job_for_update,
positions_for,
quota_detail,
serialize,
serialize_job,
serve_file,
spool_upload,
ingest_spooled,
)
from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses
@@ -328,16 +324,6 @@ def enqueue_download(
return serialize_job(db, job)
@router.post("/upload")
def upload_download(
upload: SpooledUpload = Depends(spool_upload),
db: Session = Depends(get_db),
) -> dict:
"""The Files library's upload (`routes.files.upload_file`) at its old address, until the client
moves to the new one."""
return serialize_job(db, ingest_spooled(db, upload))
class EnqueueEditIn(BaseModel):
source_job_id: int = 0
edit_spec: dict
@@ -433,24 +419,6 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
]
@router.get("/usage")
def my_usage(user: User = Depends(current_user), 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: dict[str, int] = {
JobStatus.DONE: 4,
@@ -477,90 +445,6 @@ def my_download_index(user: User = Depends(current_user), db: Session = Depends(
return out
@router.get("/shared")
def shared_with_me(user: User = Depends(current_user), 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)
positions = positions_for(db, user, jobs)
out = []
for j in jobs:
data = serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None)
data["shared"] = True
data["position_seconds"] = positions.get(j.id, 0)
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(current_user),
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(current_user), db: Session = Depends(get_db)
@@ -615,26 +499,6 @@ def cancel_download(
return serialize_job(db, job)
@router.delete("/{job_id}")
def delete_download(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""The Files library's delete (`routes.files.delete_file`, audited) at its old address, until the
client moves to the new one."""
delete_file(job_id, user, db)
return {"ok": True, "deleted": job_id}
# --- file download (range-aware, custom display name) --------------------------------------
@router.get("/{job_id}/file")
def download_file(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
response = serve_file(db, accessible_job(db, user, job_id))
db.commit()
return response
# --- editor filmstrip (scrub track) --------------------------------------------------------
@@ -807,24 +671,6 @@ def unshare_download(
return {"ok": True}
@router.delete("/shared/{job_id}")
def remove_shared_with_me(
job_id: int, user: User = Depends(current_user), 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) ------------------------------
+118 -8
View File
@@ -18,7 +18,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import BinaryIO, Literal
from urllib.parse import unquote
from urllib.parse import unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
@@ -95,7 +95,6 @@ def serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"source_job_id": job.source_job_id,
"edit_spec": job.edit_spec,
"can_download": ready and job.status == JobStatus.DONE,
"expired": job.asset_id is None and job.status == JobStatus.DONE,
# The Files library: its folder, whether the user keeps it, and when it goes (NULL = never).
"folder_id": job.folder_id,
"kept": job.kept,
@@ -468,6 +467,42 @@ def delete_folder(
return {"ok": True, "deleted": folder_id, "files_moved": moved}
@router.get("/usage")
def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""The caller's storage 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"),
}
@router.delete("/shared/{job_id}")
def remove_shared_with_me(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Remove a file 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}
# --- files ---------------------------------------------------------------------------------
@@ -489,7 +524,8 @@ def list_files(
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
.where(DownloadJob.status == JobStatus.DONE)
)
if scope == "shared":
shared = scope == "shared"
if shared:
stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where(
DownloadShare.to_user_id == user.id
)
@@ -507,8 +543,10 @@ def list_files(
func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like),
)
)
# A file shared with the caller was added to THEIR list when it was shared, not when its owner
# downloaded it.
key = {
"added": DownloadJob.created_at,
"added": DownloadShare.created_at if shared else DownloadJob.created_at,
"name": collated(name, lang_for_user(user)),
"size": MediaAsset.size_bytes,
}[sort]
@@ -517,20 +555,83 @@ def list_files(
jobs = db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars().all()
assets = assets_for(db, jobs)
positions = positions_for(db, user, jobs)
shared = scope == "shared"
shared_at = _shared_at(db, user, jobs) if shared else {}
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),
"shared": shared,
"shared_at": iso(shared_at.get(j.id)),
"source_key": sources[j.id],
}
for j in jobs
]
def _source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]:
"""Where each file came from (its source folder's key), for the list's Source column and the
"back to its source folder" move: a file in a source folder has its folder's key; one moved into
an own folder (or someone else's, shared) is traced back by `folders.source_key_for`."""
ids = {j.folder_id for j in jobs if j.folder_id is not None}
by_id = (
{f.id: f for f in db.execute(select(FileFolder).where(FileFolder.id.in_(ids))).scalars()}
if ids
else {}
)
out: dict[int, str] = {}
for j in jobs:
folder = by_id.get(j.folder_id) if j.folder_id is not None else None
if folder is not None and folder.kind == folders.FolderKind.SOURCE and folder.source_key:
out[j.id] = folder.source_key
else:
out[j.id] = folders.source_key_for(db, j)
return out
def _shared_at(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, datetime]:
rows = db.execute(
select(DownloadShare.job_id, DownloadShare.created_at).where(
DownloadShare.to_user_id == user.id,
DownloadShare.job_id.in_([j.id for j in jobs]),
)
).all()
return {job_id: at for job_id, at in rows}
_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
# `urlparse` raises on a malformed authority; this function 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 UpdateFileIn(BaseModel):
# Presence (model_fields_set) decides what is touched; a null/"" name falls back to the title.
# Presence (model_fields_set) decides what is touched; a null/"" text falls back to the system
# value (the title, the channel). All display-only: the file on disk keeps its own name.
name: str | None = None
uploader: str | None = None
uploader_url: str | None = None
extra_links: list | None = None
folder_id: int | None = None
kept: bool | None = None
@@ -542,12 +643,21 @@ def update_file(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Rename a file (its display name; the file on disk keeps its own), move it to another folder
(`folders.move`), or keep it until deleted / hand it back to its folder's retention."""
"""Rename a file and its channel, set its reference links (all display-only: the file on disk
keeps its own name), move it to another folder (`folders.move`), or keep it until deleted /
hand it back to its folder's retention."""
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
sent = body.model_fields_set
if "name" in sent:
job.display_name = display_text(body.name)
if "uploader" in sent:
job.display_uploader = display_text(body.uploader)
if "uploader_url" in sent:
job.display_uploader_url = _clean_url(body.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
if "folder_id" in sent:
if body.folder_id is None:
raise refused(400, "file_folder_gone")
+2 -2
View File
@@ -367,7 +367,7 @@ def test_every_body_reading_site_is_known_and_fits_under_its_ceiling():
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
# The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather
# than being a constant to compare against — that pairing is its own test below.
assert ("POST", "/api/downloads/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
assert ("POST", "/api/files/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
def test_release_read_connection_ends_the_transaction_it_was_called_for(db):
@@ -487,7 +487,7 @@ def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
`errors.tooLarge` exists and why the dialog checks the file against `upload_max_bytes` first.
"""
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/files/upload")]
assert spec.max == edge
+1 -2
View File
@@ -18,7 +18,7 @@ from sqlalchemy import select
from app.db import SessionLocal
from app.downloads import service
from app.models import DownloadJob, MediaAsset, User
from app.routes.downloads import cancel_download, delete_download, pause_download
from app.routes.downloads import cancel_download, pause_download
from app.routes.files import delete_file, own_job_for_update
_REF = "https://example.com/video"
@@ -54,7 +54,6 @@ def _asset_held_by_two_jobs(db, status="error"):
("status", "transition", "route", "left"),
[
("error", service.cancel, cancel_download, "canceled"),
("error", service.delete, delete_download, None),
("error", service.delete, delete_file, None),
("queued", service.cancel, pause_download, "canceled"),
],
+1 -1
View File
@@ -77,7 +77,7 @@ def test_resume_resets_a_failed_shared_asset_to_pending(db, as_user):
def test_delete_releases_and_removes_the_job(db, as_user):
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
r = as_user(u).request("DELETE", f"/api/downloads/{job.id}")
r = as_user(u).request("DELETE", f"/api/files/{job.id}")
assert r.status_code == 200, r.text
db.expire_all()
assert db.get(DownloadJob, job.id) is None
+76
View File
@@ -257,6 +257,54 @@ def test_shared_with_me_lists_what_others_shared_and_nothing_else(db, make_user,
assert [(f["id"], f["shared"]) for f in got] == [(theirs.id, True)]
def test_shared_with_me_is_ordered_by_when_it_was_shared_and_can_be_dismissed(
db, make_user, as_user
):
"""A friend's old file shared today is new to me: "added" is the share's date there. Dismissing
one drops only my grant; the owner's file stays."""
alice, bob = make_user(), make_user()
old = _file(db, bob.id, created_at=NOW - timedelta(days=30))
new = _file(db, bob.id, created_at=NOW - timedelta(days=1))
db.add(
DownloadShare(
job_id=new.id,
from_user_id=bob.id,
to_user_id=alice.id,
created_at=NOW - timedelta(hours=2),
)
)
db.add(
DownloadShare(
job_id=old.id,
from_user_id=bob.id,
to_user_id=alice.id,
created_at=NOW - timedelta(hours=1),
)
)
db.commit()
c = as_user(alice)
got = c.get("/api/files", params={"scope": "shared"}).json()
assert [f["id"] for f in got] == [old.id, new.id]
assert got[0]["shared_at"] is not None and got[0]["shared_at"] > got[1]["shared_at"]
r = c.delete(f"/api/files/shared/{old.id}")
assert r.json() == {"ok": True, "removed": old.id}
assert [f["id"] for f in c.get("/api/files", params={"scope": "shared"}).json()] == [new.id]
assert db.get(DownloadJob, old.id) is not None
def test_each_listed_file_says_where_it_came_from_wherever_it_sits(db, make_user, as_user):
"""The Source column: a file's own source folder, also after it moved into an own folder."""
alice = make_user()
yt = _file(db, alice.id)
site = _file(db, alice.id, kind="url", ref="https://www.tiktok.com/@x/video/1")
moved = _file(db, alice.id)
own = _own(db, alice.id)
c = as_user(alice)
assert c.patch(f"/api/files/{moved.id}", json={"folder_id": own.id}).status_code == 200
got = {f["id"]: f["source_key"] for f in c.get("/api/files").json()}
assert got == {yt.id: "youtube", site.id: "site:tiktok.com", moved.id: "youtube"}
# --- one file ------------------------------------------------------------------------------
@@ -279,6 +327,34 @@ def test_a_file_is_renamed_kept_and_given_back_to_its_folders_retention(db, make
)
def test_a_files_channel_and_links_are_set_and_only_plausible_links_are_kept(
db, make_user, as_user
):
alice = make_user()
job = _file(db, alice.id)
c = as_user(alice)
got = c.patch(
f"/api/files/{job.id}",
json={
"uploader": " Saját csatorna ",
"uploader_url": "mailto:someone@example.com",
"extra_links": ["https://a.example/x", "ftp://b.example", "", 7, "http://[::1/x"]
+ [f"https://c.example/{i}" for i in range(10)],
},
).json()
assert got["display_uploader"] == "Saját csatorna"
assert got["display_uploader_url"] is None
assert got["extra_links"][0] == "https://a.example/x" and len(got["extra_links"]) == 8
cleared = c.patch(f"/api/files/{job.id}", json={"uploader": "", "extra_links": []}).json()
assert (cleared["display_uploader"], cleared["extra_links"]) == (None, [])
def test_the_usage_carries_the_quota_and_the_upload_cap(db, make_user, as_user):
got = as_user(make_user()).get("/api/files/usage").json()
assert got["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
assert "footprint_bytes" in got and "max_bytes" in got
def test_a_file_moves_to_an_own_folder_or_home_and_nowhere_else(db, make_user, as_user):
alice, bob = make_user(), make_user()
job = _file(db, alice.id)
+21 -5
View File
@@ -9,13 +9,26 @@ cannot reach, is ever read or written. DB-backed (real Postgres).
import pytest
from sqlalchemy import select
from app.models import DownloadJob, DownloadShare, LibraryState
from app.models import DownloadJob, DownloadShare, LibraryState, MediaAsset
from app.routes._common import ProgressIn, kept_position
def _done_job(db, user) -> DownloadJob:
asset = MediaAsset(
source_kind="url",
source_ref="https://example.com/a.mp4",
format_sig=f"sig-{user.id}",
status="ready",
rel_path=f"a-{user.id}.mp4",
ref_count=1,
media_kind="video",
container="mp4",
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=user.id,
asset_id=asset.id,
source_kind="url",
source_ref="https://example.com/a.mp4",
status="done",
@@ -59,7 +72,7 @@ def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
"position_seconds": 754,
}
[item] = c.get("/api/downloads").json()
[item] = c.get("/api/files").json()
assert item["position_seconds"] == 754
# A second checkpoint moves it rather than adding a row.
assert _checkpoint(c, job.id, 900) == 900
@@ -69,7 +82,7 @@ def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
def test_an_item_never_played_lists_zero(db, make_user, as_user):
me = make_user()
_done_job(db, me)
[item] = as_user(me).get("/api/downloads").json()
[item] = as_user(me).get("/api/files").json()
assert item["position_seconds"] == 0
@@ -110,8 +123,11 @@ def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_us
assert _checkpoint(as_user(owner), job.id, 100) == 100
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
assert as_user(owner).get("/api/files").json()[0]["position_seconds"] == 100
assert (
as_user(friend).get("/api/files", params={"scope": "shared"}).json()[0]["position_seconds"]
== 2000
)
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
+1 -3
View File
@@ -141,9 +141,7 @@ def test_what_a_link_serves_passes_the_rule(
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args, str(src)], check=True)
owner = as_user(make_user())
r = owner.post(
"/api/downloads/upload", content=src.read_bytes(), headers=upload_headers(filename)
)
r = owner.post("/api/files/upload", content=src.read_bytes(), headers=upload_headers(filename))
assert r.status_code == 200, r.text
job_id = r.json()["id"]
assert r.json()["status"] == "queued" # every one of these fails the rule as it came
+1 -3
View File
@@ -25,9 +25,7 @@ def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
owner = as_user(make_user())
job = owner.post(
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
)
job = owner.post("/api/files/upload", content=b"fake-video", headers=upload_headers("clip.mp4"))
assert job.status_code == 200, job.text
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
assert link.status_code == 200, link.text
+1 -1
View File
@@ -34,7 +34,7 @@ def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers):
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
owner = as_user(make_user())
job = owner.post(
"/api/downloads/upload",
"/api/files/upload",
content=b"fake-video",
headers=upload_headers("clip.mp4", display_name="Holiday clip"),
)
+29 -38
View File
@@ -212,7 +212,7 @@ def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
multipart the old route would have spooled, not the raw body the new one expects."""
body, pulled = _counting_multipart()
r = client.post(
"/api/downloads/upload",
"/api/files/upload",
content=body,
headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"},
)
@@ -227,7 +227,7 @@ def test_an_authenticated_upload_still_has_its_body_read(as_user, make_user, _ro
body, pulled = _counting_body()
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
"/api/files/upload",
content=body,
headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"},
)
@@ -281,7 +281,7 @@ def test_upload_route_generic_forces_download(
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
"/api/files/upload",
content=b"<script>alert(1)</script>",
headers=upload_headers("report.html"),
)
@@ -290,7 +290,7 @@ def test_upload_route_generic_forces_download(
assert job["asset"]["media_kind"] == "file"
assert job["can_download"] is True
# The served file must be a neutral force-download attachment — never inline text/html.
fr = c.get(f"/api/downloads/{job['id']}/file")
fr = c.get(f"/api/files/{job['id']}/file")
assert fr.status_code == 200
assert fr.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in fr.headers["content-disposition"].lower()
@@ -307,7 +307,7 @@ def test_upload_route_media_html_extension_not_served_as_html(
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
"/api/files/upload",
content=b"<script>alert(1)</script>",
headers=upload_headers("evil.html"),
)
@@ -324,7 +324,7 @@ def test_upload_route_media_html_extension_not_served_as_html(
db.commit()
# Authenticated endpoint: never text/html.
fr = c.get(f"/api/downloads/{job['id']}/file")
fr = c.get(f"/api/files/{job['id']}/file")
assert fr.status_code == 200
assert fr.headers["content-type"].startswith("application/octet-stream")
assert "attachment" in fr.headers["content-disposition"].lower()
@@ -348,7 +348,7 @@ def test_public_watch_file_forces_download_for_generic_file(
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload",
"/api/files/upload",
content=b"<script>alert(1)</script>",
headers=upload_headers("notes.html"),
).json()
@@ -369,7 +369,7 @@ def test_public_watch_file_serves_media_inline_with_real_type(
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload",
"/api/files/upload",
content=b"fake-mp4-bytes",
headers=upload_headers("clip.mp4"),
).json()
@@ -389,7 +389,7 @@ def test_upload_route_enforces_per_file_cap(
db.commit()
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
"/api/files/upload",
content=b"way over the cap",
headers=upload_headers("big.bin"),
)
@@ -412,14 +412,10 @@ def test_the_cap_admits_a_body_of_EXACTLY_the_cap_and_refuses_one_byte_more(
db.commit()
c = as_user(make_user())
at_the_cap = c.post(
"/api/downloads/upload", content=payload, headers=upload_headers("exact.bin")
)
at_the_cap = c.post("/api/files/upload", content=payload, headers=upload_headers("exact.bin"))
assert at_the_cap.status_code == 200, at_the_cap.text
over = c.post(
"/api/downloads/upload", content=payload + b"x", headers=upload_headers("over.bin")
)
over = c.post("/api/files/upload", content=payload + b"x", headers=upload_headers("over.bin"))
assert over.status_code == 422
assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)}
@@ -437,9 +433,7 @@ def test_a_non_ascii_filename_survives_the_header_round_trip(
"""
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
r = c.post(
"/api/downloads/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")
)
r = c.post("/api/files/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin"))
assert r.status_code == 200, r.text
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
assert asset is not None
@@ -467,9 +461,7 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
c = as_user(make_user())
# 1. the upload route — the name arrives in a request header.
up = c.post(
"/api/downloads/upload", content=b"video", headers=upload_headers("a.mp4", long_name)
)
up = c.post("/api/files/upload", content=b"video", headers=upload_headers("a.mp4", long_name))
assert up.status_code == 200, up.text
assert up.json()["display_name"] == bound
@@ -494,10 +486,10 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
assert edit.status_code == 200, edit.text
assert edit.json()["display_name"] == bound
# 4 + 5. the metadata PATCH's two fields.
# 4 + 5. the library PATCH's two fields.
patched = c.patch(
f"/api/downloads/{up.json()['id']}",
json={"display_name": long_name, "display_uploader": long_name},
f"/api/files/{up.json()['id']}",
json={"name": long_name, "uploader": long_name},
)
assert patched.status_code == 200, patched.text
assert patched.json()["display_name"] == bound
@@ -610,12 +602,11 @@ DISPLAY_WRITE_SITES = {
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
("routes/downloads.py", "enqueue_clip"): {"display_text(body.display_name)", "display_name"},
("routes/downloads.py", "update_download_meta"): {
"display_text(body.display_name)",
"display_text(body.display_uploader)",
},
("routes/files.py", "ingest_spooled"): {"upload.display_name"},
("routes/files.py", "update_file"): {"display_text(body.name)"},
("routes/files.py", "update_file"): {
"display_text(body.name)",
"display_text(body.uploader)",
},
("routes/files.py", "spool_upload"): {
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
},
@@ -666,7 +657,7 @@ def test_the_display_name_header_reaches_the_job_and_is_bounded(
c = as_user(make_user())
ok = c.post(
"/api/downloads/upload",
"/api/files/upload",
content=b"data",
headers=upload_headers("clip.bin", "Nyári vakáció"),
)
@@ -675,7 +666,7 @@ def test_the_display_name_header_reaches_the_job_and_is_bounded(
long_name = "n" * 300
bounded = c.post(
"/api/downloads/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
"/api/files/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
)
assert bounded.status_code == 200, bounded.text
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
@@ -718,7 +709,7 @@ def test_the_upload_holds_no_db_transaction_while_the_body_streams(
monkeypatch.setattr(files_routes, "release_read_connection", spy_release)
monkeypatch.setattr(files_routes, "capped_stream_to", spy_stream)
c = as_user(make_user())
r = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("held.bin"))
r = c.post("/api/files/upload", content=b"data", headers=upload_headers("held.bin"))
assert r.status_code == 200, r.text
assert seen["before"] is True, (
"nothing had opened a transaction before the release, so this test proves nothing — the "
@@ -740,14 +731,14 @@ def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db)
from app import sysconfig
c = as_user(make_user())
r = c.get("/api/downloads/usage")
r = c.get("/api/files/usage")
assert r.status_code == 200, r.text
assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
# And it is the KNOB, not a constant: a lowered override must show through.
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
db.commit()
assert c.get("/api/downloads/usage").json()["upload_max_bytes"] == 4096
assert c.get("/api/files/usage").json()["upload_max_bytes"] == 4096
def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
@@ -765,7 +756,7 @@ def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
staging = Path(settings.files_root) / ".staging" / "uploads"
c = as_user(make_user())
r = c.post("/api/downloads/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin"))
r = c.post("/api/files/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin"))
assert r.status_code == 422
assert staging.exists(), "the dependency did not get as far as creating the staging dir"
assert list(staging.iterdir()) == [], "a refused upload left its partial file behind"
@@ -787,7 +778,7 @@ def test_upload_cap_cannot_be_disabled_with_zero(db):
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers):
c = as_user(make_user())
r = c.post("/api/downloads/upload", content=b"", headers=upload_headers("empty.bin"))
r = c.post("/api/files/upload", content=b"", headers=upload_headers("empty.bin"))
assert r.status_code == 400
assert r.json()["detail"]["code"] == "upload_empty"
@@ -795,7 +786,7 @@ def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch,
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json()
r = c.post(f"/api/downloads/{job['id']}/redownload")
assert r.status_code == 409
@@ -805,7 +796,7 @@ def test_edit_rejects_a_generic_upload_source(
):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json()
r = c.post(
"/api/downloads/edit",
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
+2 -2
View File
@@ -549,7 +549,7 @@ def test_a_link_says_preparing_only_while_its_video_is_being_made(
_probe(monkeypatch, vcodec="h264")
c = as_user(make_user())
job = c.post(
"/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
"/api/files/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
).json()
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
assert c.get(f"/api/public/watch/{link['token']}").status_code == 200
@@ -570,7 +570,7 @@ def test_a_link_to_a_suspended_owners_waiting_rebuild_says_it_is_gone(
owner = make_user()
c = as_user(owner)
job = c.post(
"/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
"/api/files/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
).json()
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
db.get(MediaAsset, job["asset"]["id"]).status = "pending"