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