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
+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) ------------------------------