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"
+18 -7
View File
@@ -87,6 +87,7 @@ export default function App() {
feed: seedLayout("feed", prefs),
plex: seedLayout("plex", prefs),
playlists: seedLayout("playlists", prefs),
files: seedLayout("files", prefs),
};
});
const [aboutOpen, setAboutOpen] = useState(false);
@@ -119,6 +120,17 @@ export default function App() {
localKey: LS.playlistsCollapsed,
parse: Boolean,
});
// The Files rail is the library's folder list — how you move around it, not a filter you set once —
// so it starts pinned open (the owner's mockup), and has its own flag.
const [filesCollapsed, setFilesCollapsed] = usePref<boolean>("filesCollapsed", false, {
localKey: LS.filesCollapsed,
parse: Boolean,
});
const railCollapse = {
filter: [filterCollapsed, setFilterCollapsed],
playlists: [playlistsCollapsed, setPlaylistsCollapsed],
files: [filesCollapsed, setFilesCollapsed],
} as const;
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
@@ -378,18 +390,17 @@ export default function App() {
/>
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
content. feed + plex share the filter-rail collapse flag, playlists has its own. */}
content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */}
{railDef && (
<Suspense fallback={null}>
<railDef.Rail
layout={panelLayouts[railDef.panelId]}
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
onToggleCollapse={() =>
railDef.collapse === "playlists"
? setPlaylistsCollapsed(!playlistsCollapsed)
: setFilterCollapsed(!filterCollapsed)
}
collapsed={railCollapse[railDef.collapse][0]}
onToggleCollapse={() => {
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
setCollapsed(!collapsed);
}}
/>
</Suspense>
)}
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
//
// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
// `confirmThen` once labelled it with the dialog's question — the cancel dialog's own "Cancel" beside
// "Cancel". A finished file's confirms are the Files page's (Files.menu.dom.test.tsx). i18n is
// key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -73,7 +74,6 @@ const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
folder_id: null,
kept: false,
expires_at: null,
@@ -105,7 +105,7 @@ async function pick(row: string, label: string) {
describe("DownloadCenter confirm dialogs", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.jobs = [job(1, "running"), job(2, "done")];
mocks.jobs = [job(1, "running")];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
@@ -115,18 +115,9 @@ describe("DownloadCenter confirm dialogs", () => {
// The destructive and rare row buttons live in the row's "…".
await pick("Clip 1", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
// The tab's label shares its button with a count badge.
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 2");
await pick("Clip 2", "downloads.actions.redownload");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
await pick("Clip 2", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
});
@@ -163,24 +154,4 @@ describe("DownloadCenter confirm dialogs", () => {
"downloads.confirm.cancelEditAction",
]);
});
it.each([
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
] as const)("deleting a finished %s says what happens to its file", async (kind, title, body) => {
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
mocks.jobs = [{ ...job(5, "done"), job_kind: kind }];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 5");
await pick("Clip 5", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const [opts] = mocks.confirm.mock.calls[0]!;
expect([opts.title, opts.message]).toEqual([title, body]);
});
});
@@ -1,10 +1,9 @@
// @vitest-environment jsdom
//
// The Download Center: a download row keeps its primary action (play; pause / resume /
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
// menu. i18n is key-passthrough, so labels read as keys.
// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and
// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a
// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx).
// i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -68,7 +67,6 @@ const job = (
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
job_kind: "download",
asset: { media_kind: "video", duration_s: 60 },
...extra,
@@ -209,160 +207,6 @@ describe("DownloadCenter · the queue row", () => {
});
});
describe("DownloadCenter · the library row", () => {
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"downloads.actions.editDetails",
"downloads.actions.redownload",
"downloads.actions.shareManage",
"common.copy.title",
"common.copy.link",
"downloads.actions.delete",
]);
});
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
// Starting over opens the player at 0:00, not at the kept point.
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle")!,
);
const video = await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
return v!;
});
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 60, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
expect(video.currentTime).toBe(0);
});
it("an item never played offers Play only, with no bar", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).not.toContain("card.restartTitle");
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount("done");
await screen.findByText("Clip 2");
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
it("saves the file through the same link as the row's save button", async () => {
mocks.jobs = [job(2, "done")];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(this.href);
});
mount("done");
await screen.findByText("Clip 2");
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.download")!,
);
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(followed).toEqual([button.href]);
});
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mount("done");
await screen.findByText("Clip 3");
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
expect(menuRows()).toEqual([
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.shareManage",
"common.copy.title",
"downloads.actions.delete",
]);
});
it("gives a link inside the row the browser's own menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
const link = await screen.findByText("youtu.be/x");
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
act(() => void link.dispatchEvent(ev));
expect(ev.defaultPrevented).toBe(false);
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
});
describe("DownloadCenter · the shared row", () => {
it("holds play, save, edit, the copies and Remove from my list", async () => {
mocks.jobs = [];
vi.spyOn(api, "downloadsShared").mockResolvedValue([
job(4, "done", { can_download: true }),
] as never);
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
mount("shared");
await screen.findByText("Clip 4");
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.removeShared")!,
);
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
});
});
describe("menuGroups", () => {
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
it("rules between non-empty groups only", () => {
+11 -141
View File
@@ -1,47 +1,29 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
ClipboardPaste,
Download,
Pause,
Play,
PlayCircle,
Plus,
RefreshCw,
Settings2,
Share2,
UploadCloud,
} from "lucide-react";
import { ClipboardPaste, Pause, Play, Plus, RefreshCw, Settings2, UploadCloud } from "lucide-react";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
const UploadDialog = lazy(() => import("./UploadDialog"));
const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { DownloadRow, IconBtn } from "./downloadRow";
import { ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
import { useDownloadActions } from "./useDownloadActions";
const jobKey = (job: DownloadJob) => job.id;
@@ -55,6 +37,9 @@ async function clipboardText(): Promise<string | null> {
}
}
// The download queue: what is downloading, waiting or failed, the link box and the upload, the
// download profiles, and (admin) the instance's downloads. A finished download is a library file and
// lives in Files.
export default function DownloadCenter() {
const me = useMe();
const { t } = useTranslation();
@@ -66,12 +51,6 @@ export default function DownloadCenter() {
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false);
const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
// What the player opens, and whether over the kept point ("Play from the beginning").
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
// Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled
@@ -81,29 +60,15 @@ export default function DownloadCenter() {
intervalMs: (data) =>
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
});
const sharedQ = useQuery({
queryKey: qk.downloadsShared(),
queryFn: api.downloadsShared,
enabled: tab === "shared",
});
const jobs = jobsQ.data ?? [];
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done");
// --- The rows' ONE menu -------------------------------------------------------------
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
// open menu, and its "…" must still close it. The tab is the one on screen.
// open menu, and its "…" must still close it.
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
const actions = useDownloadActions({
onPlay: setPlayJob,
onEdit: setEditJob,
onEditDetails: setMetaJob,
onManageShares: setShareJob,
});
const menuTarget = rowMenu.target;
const listTab: DownloadTab | null =
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
const actions = useDownloadActions();
const menuItems = rowMenu.target && tab === "queue" ? actions.items(rowMenu.target) : [];
// The row's menu triggers and its "…".
const rowMenuProps = (job: DownloadJob) => {
const mine = rowMenu.open && rowMenu.target?.id === job.id;
@@ -133,8 +98,6 @@ export default function DownloadCenter() {
const tabs = [
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
{ id: "done", label: t("downloads.tabs.done"), badge: library.length },
{ id: "shared", label: t("downloads.tabs.shared") },
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
];
// Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
@@ -144,12 +107,6 @@ export default function DownloadCenter() {
// account), so a render-phase write is allowed, and it converges — "queue" is always in the list.
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
const saveBtn = (job: DownloadJob) => (
<IconLink href={api.downloadFileUrl(job.id)} label={t("downloads.actions.download")} size="md">
<Download />
</IconLink>
);
return (
<>
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
@@ -268,83 +225,6 @@ export default function DownloadCenter() {
</>
)}
{tab === "done" && (
<>
<UsageBar />
<NavGrid className="space-y-2">
{library.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{libraryRowActions(job).play && (
<IconBtn
open
onClick={() => setPlayJob(job)}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{/* Sharing the link is the library's main errand: in sight, ahead of save. */}
<IconBtn onClick={() => actions.share(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{saveBtn(job)}
</DownloadRow>
))}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
))}
</NavGrid>
</>
)}
{tab === "shared" && (
<NavGrid className="space-y-2">
{(sharedQ.data ?? []).map((job, i) => {
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{a.play && (
<IconBtn
open
onClick={() => setPlayJob(job)}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{a.save && saveBtn(job)}
</DownloadRow>
);
})}
{(sharedQ.data ?? []).length === 0 &&
(sharedQ.isError && !sharedQ.data ? (
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : sharedQ.isLoading ? (
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
</div>
))}
</NavGrid>
)}
{tab === "system" && me.role === "admin" && <AdminSystem />}
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
@@ -361,16 +241,6 @@ export default function DownloadCenter() {
)}
{upload && <UploadDialog onClose={() => setUpload(false)} />}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{playing && (
<LibraryPlayer
job={playing.job}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
job={errorJob}
@@ -0,0 +1,397 @@
// @vitest-environment jsdom
//
// The Files page: a row keeps Copy link, Share, Save and its "…" in sight — a slot an action does not
// apply to stays empty, so the icons line up down the list — and its every action is its ONE menu,
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
// list. i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type FileFolder, type LibraryFile } from "../lib/api";
import { ALL_FILES, FilesActionsContext, FilesStateContext, type FilesView } from "./filesContext";
const mocks = vi.hoisted(() => ({
files: [] as unknown[],
folders: [] as unknown[],
confirm: vi.fn(async (_o?: unknown) => true),
keys: [] as unknown[],
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && "folder" in o ? `${key}:${String(o.folder)}` : key,
}),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => {
mocks.keys.push(key);
return {
data:
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
};
},
}));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import Files from "./Files";
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({
id,
status: "done",
progress: 100,
created_at: "2026-10-10T10:00:00+00:00",
source_kind: "youtube",
source_ref: `v${id}`,
source_url: null,
poster_url: null,
display_name: `Clip ${id}`,
display_uploader: null,
extra_links: [],
can_download: true,
job_kind: "download",
folder_id: 1,
kept: false,
expires_at: null,
shared: false,
source_key: "youtube",
asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 },
...extra,
}) as unknown as LibraryFile;
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
id,
kind: "source",
source_key: null,
name: null,
retention_days: 30,
count: 1,
size_bytes: 0,
created_at: null,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
mocks.keys = [];
});
function mount(view: FilesView = ALL_FILES, setView = vi.fn(), search = "") {
render(
<QueryClientProvider client={new QueryClient()}>
<FilesActionsContext.Provider value={{ setSearch: vi.fn(), setView }}>
<FilesStateContext.Provider value={{ search, view }}>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
return setView;
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!;
// A folder in the Move submenu is a choice of one (menuitemradio), the rest plain rows.
const menuEls = () => [
...screen.getAllByRole("menuitem", { hidden: true }),
...screen.queryAllByRole("menuitemradio", { hidden: true }),
];
const menuRows = () => menuEls().map((b) => b.textContent ?? "");
const menuItem = (label: string) => menuEls().find((b) => b.textContent === label)!;
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const rowButtons = (text: string) =>
[...rowOf(text).querySelectorAll<HTMLElement>("button[aria-label], a[aria-label]")].map((b) =>
b.getAttribute("aria-label"),
);
describe("Files · my file's row", () => {
it("shows Copy link, Share, Save and the '…'; the menu holds every action", async () => {
mocks.files = [file(2, { source_url: "https://youtu.be/x" })];
mocks.folders = [YT, OWN];
mount();
expect(rowButtons("Clip 2")).toEqual([
"files.actions.copyLink",
"files.actions.share",
"downloads.actions.download",
"files.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"files.actions.copyLink",
"files.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"files.actions.rename",
"files.actions.move",
"files.actions.keep",
"files.actions.details",
"downloads.actions.redownload",
"common.copy.title",
"files.actions.copySourceLink",
"downloads.actions.delete",
]);
});
it("keeps the slot of an action a plain file lacks, so the icons line up", () => {
mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mocks.folders = [YT];
mount();
expect(rowButtons("Clip 3")).toEqual([
"files.actions.share",
"downloads.actions.download",
"files.rowMenu",
]);
// The empty slot holds the Copy link's place, the same width as an icon.
const actions = rowOf("Clip 3").querySelector(
'button[aria-label="files.actions.share"]',
)!.parentElement!;
expect(actions.firstElementChild!.getAttribute("aria-hidden")).toBe("true");
});
it("copies the link from the row's own icon", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount();
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="files.actions.copyLink"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
});
it("saves through the file's own address", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(new URL(this.href).pathname);
});
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.download"));
expect(followed).toEqual(["/api/files/2/file"]);
const icon = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(new URL(icon.href).pathname).toBe("/api/files/2/file");
});
it("renames in the row: Enter saves, an emptied name gives the title back", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(field, { target: { value: " Új név " } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const again = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(again, { target: { value: "" } });
fireEvent.keyDown(again, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null }));
});
it("Escape leaves the name as it was", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(field, { target: { value: "Más" } });
fireEvent.keyDown(field, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
expect(update).not.toHaveBeenCalled();
});
it("moves to an own folder, and from there back to its source folder only", async () => {
mocks.files = [file(2, { folder_id: 2 })];
// Another source folder first, so "its own" is chosen by its key, not by its place in the list.
mocks.folders = [folder(3, { source_key: "site:tiktok.com" }), YT, OWN];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("Cimboráknak"));
// The folder it is in, ticked; home is its own source folder, never another site's.
expect(menuRows()).toContain("files.actions.moveHome:files.source.youtube");
expect(menuRows().some((r) => r.includes("tiktok"))).toBe(false);
expect(menuItem("Cimboráknak").getAttribute("aria-checked")).toBe("true");
fireEvent.click(menuItem("files.actions.moveHome:files.source.youtube"));
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
});
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
mocks.files = [file(2, { folder_id: 1 })];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("files.actions.noOwnFolders"));
expect(menuItem("files.actions.noOwnFolders").hasAttribute("disabled")).toBe(true);
});
it("keeps a file, and a kept one is handed back to its folder", async () => {
mocks.files = [file(2), file(3, { kept: true })];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
expect(rowOf("Clip 3").textContent).toContain("files.retention.kept");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.keep"));
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { kept: true }));
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
fireEvent.click(menuItem("files.actions.unkeep"));
await waitFor(() => expect(update).toHaveBeenLastCalledWith(3, { kept: false }));
});
it("deletes only through the confirm", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const del = vi.spyOn(api, "deleteFile").mockResolvedValue({} as never);
mocks.confirm.mockResolvedValueOnce(false);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(del).not.toHaveBeenCalled();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
});
});
describe("Files · the confirms", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mocks.confirm.mockResolvedValue(false);
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.redownload"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
expect(
mocks.confirm.mock.calls.map(([o]) => {
const opts = o as { title: string; confirmLabel?: string };
return [opts.title, opts.confirmLabel];
}),
).toEqual([
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
mocks.confirm.mockResolvedValue(true);
});
it.each([
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
] as const)("deleting a %s says what happens to its file", async (kind, title, body) => {
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
mocks.files = [file(5, { job_kind: kind })];
mocks.folders = [YT];
mocks.confirm.mockResolvedValueOnce(false);
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 5")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string };
expect([opts.title, opts.message]).toEqual([title, body]);
});
});
describe("Files · a shared file's row", () => {
it("plays, saves, cuts, copies and drops it from my list — nothing of the owner's", async () => {
mocks.files = [file(4, { shared: true, shared_at: "2026-10-11T08:00:00+00:00" })];
mocks.folders = [YT];
const remove = vi.spyOn(api, "removeSharedFile").mockResolvedValue({} as never);
mount({ kind: "shared" });
expect(rowButtons("Clip 4")).toEqual(["downloads.actions.download", "files.rowMenu"]);
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(menuItem("downloads.actions.removeShared"));
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
// The shared list is its own read, and nothing can be uploaded into it.
expect(mocks.keys).toContainEqual(["files", { shared: true }]);
expect(screen.queryByText("files.actions.upload")).toBeNull();
});
});
describe("Files · the view", () => {
it("reads one folder's files and names the folder", () => {
mocks.files = [file(2, { folder_id: 2 })];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 2 });
expect(mocks.keys).toContainEqual(["files", { folderId: 2 }]);
expect(screen.getByRole("heading", { name: "Cimboráknak" })).toBeTruthy();
});
it("opens the whole library when the folder it was given is gone", async () => {
mocks.files = [];
mocks.folders = [YT];
const setView = mount({ kind: "folder", id: 99 });
expect(mocks.keys).toContainEqual(["files", {}]);
expect(mocks.keys).not.toContainEqual(["files", { folderId: 99 }]);
await waitFor(() => expect(setView).toHaveBeenCalledWith(ALL_FILES));
});
it("searches the rows it holds by name and channel", () => {
mocks.files = [
file(2, { display_name: "Szméagol szompolyog" }),
file(3, { display_name: "Más", display_uploader: "Szméagol rajongó" }),
file(4, { display_name: "Harmadik" }),
];
mocks.folders = [YT];
mount(ALL_FILES, vi.fn(), "szméagol");
expect(screen.queryByText("Szméagol szompolyog")).not.toBeNull();
expect(screen.queryByText("Más")).not.toBeNull();
expect(screen.queryByText("Harmadik")).toBeNull();
});
});
+478
View File
@@ -0,0 +1,478 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Clock,
Download,
Link,
Pin,
Scissors,
Share2,
Smartphone,
UploadCloud,
} from "lucide-react";
import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
import { qk } from "../lib/queryKeys";
import { LS } from "../lib/storage";
import { useLiveQuery } from "../lib/useLiveQuery";
import { JobThumb } from "./downloadRow";
import { MetaEditModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { folderLabel, sourceIcon, sourceLabel } from "./fileFolders";
import { ALL_FILES, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { ContextMenu } from "./ui/ContextMenu";
import DataTable, { type Column } from "./ui/DataTable";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { MoreButton } from "./ui/MoreButton";
import { PageToolbar } from "./ui/PageShell";
import { Pressable } from "./ui/Pressable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useContextMenu } from "./ui/useContextMenu";
// Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in
// particular is heavy — filmstrip + scrubber).
const UploadDialog = lazy(() => import("./UploadDialog"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
const VideoEditor = lazy(() => import("./VideoEditor"));
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
// picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of
// value in its own column, an action that does not apply leaving its slot empty, the same menu on a
// right click and on the "…" (DESIGN.md "Files page", "Library list columns").
const DAY_MS = 24 * 60 * 60 * 1000;
// A file this close to leaving says so in the warning colour.
const SOON_DAYS = 7;
const fileKey = (f: LibraryFile) => f.id;
/** The list a view reads. The search is applied here, on the rows (the list is the whole library,
* capped server-side): a request per keystroke would buy nothing the rows do not already hold. */
function listParams(view: FilesView): FileListParams {
if (view.kind === "shared") return { shared: true };
if (view.kind === "folder") return { folderId: view.id };
return {};
}
function matches(file: LibraryFile, q: string, lang: string): boolean {
if (!q) return true;
const hay = `${jobTitle(file)} ${file.display_uploader ?? file.asset?.uploader ?? ""}`;
return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang));
}
/** Where an upload from this view lands: the folder itself when it may take one (an own folder, the
* Uploads folder — `folders.upload_target`), else the Uploads folder. */
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
if (view.kind !== "folder") return null;
const f = folders.find((x) => x.id === view.id);
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
}
function daysLeft(iso: string): number {
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS));
}
function RetentionCell({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
if (file.kept)
return (
<Badge>
<Pin />
{t("files.retention.kept")}
</Badge>
);
if (!file.expires_at)
return (
<span className="text-xs text-muted" title={t("files.retention.never")}>
—
</span>
);
const days = daysLeft(file.expires_at);
const when = formatDateTime(file.expires_at, i18n.language);
return days <= SOON_DAYS ? (
<Badge tone="warn" title={t("files.retention.until", { when })}>
<Clock />
{t("files.retention.days", { count: days })}
</Badge>
) : (
<span className="text-xs tabular-nums text-muted" title={t("files.retention.until", { when })}>
{t("files.retention.days", { count: days })}
</span>
);
}
function NameCell({
file,
renaming,
onPlay,
onRenamed,
}: {
file: LibraryFile;
renaming: boolean;
onPlay: (() => void) | null;
onRenamed: (name: string | null) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(() => jobTitle(file));
const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip";
if (renaming)
return (
<Input
size="sm"
className="w-full"
autoFocus
value={draft}
aria-label={t("files.actions.rename")}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => onRenamed(draft)}
onKeyDown={(e) => {
// preventDefault: a real Enter's own activation would otherwise press what focus lands on.
if (e.key === "Enter") {
e.preventDefault();
onRenamed(draft);
}
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") {
e.preventDefault();
onRenamed(null);
}
}}
/>
);
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
return (
<div className="flex min-w-0 flex-col">
{onPlay ? (
<Pressable
selectable
data-nav-open=""
onClick={onPlay}
title={jobTitle(file)}
className="min-w-0 rounded-sm hover:text-accent"
>
{title}
</Pressable>
) : (
<span title={jobTitle(file)}>{title}</span>
)}
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span className="truncate">{channel ?? "—"}</span>
{clip && (
<Badge>
<Scissors />
{t("downloads.clipBadge")}
</Badge>
)}
</span>
</div>
);
}
export default function Files() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { search, view } = useFiles();
const { setView } = useFilesActions();
const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 });
const folders = foldersQ.data ?? [];
// A remembered or bookmarked folder that is gone (deleted, or another account's id) opens the whole
// library instead of an empty page that names nothing; the provider is told, so the rail agrees.
const gone = view.kind === "folder" && !!foldersQ.data && !folders.some((f) => f.id === view.id);
const shown = gone ? ALL_FILES : view;
useEffect(() => {
if (gone) setView(ALL_FILES);
}, [gone, setView]);
const params = listParams(shown);
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own.
const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
const all = filesQ.data ?? [];
const q = search.trim();
const rows = all.filter((f) => matches(f, q, lang));
const [renamingId, setRenamingId] = useState<number | null>(null);
const [upload, setUpload] = useState(false);
const [metaFile, setMetaFile] = useState<LibraryFile | null>(null);
const [shareFile, setShareFile] = useState<LibraryFile | null>(null);
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null);
const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart });
const actions = useFileActions({
folders,
onPlay: play,
onEdit: setEditFile,
onRename: (f) => setRenamingId(f.id),
onEditDetails: setMetaFile,
onManageShares: setShareFile,
});
// Keyed by id: the list re-reads every 15 s, replacing the file under an open menu, and its "…"
// must still close it.
const rowMenu = useContextMenu<LibraryFile>({ keyOf: fileKey });
const menuItems = rowMenu.target ? actions.items(rowMenu.target) : [];
const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined;
// A folder view names its folder once the folder list is in (blank until then, never a guess).
const title =
shown.kind === "shared"
? t("files.rail.shared")
: shown.kind === "folder"
? open
? folderLabel(open, t)
: ""
: t("files.rail.all");
const canUpload = shown.kind !== "shared";
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
const columns: Column<LibraryFile>[] = [
{
key: "thumb",
header: "",
width: "5.5rem",
hideInCard: true,
// A narrow table (a pinned rail beside it) gives its fixed columns' room to the name: with the
// picture and the date in, the name had none left.
narrowHidden: true,
render: (f) => (
<JobThumb
job={f}
box="w-20 aspect-video shrink-0 rounded-md overflow-hidden bg-surface border border-border"
/>
),
},
{
key: "name",
header: t("files.columns.name"),
cardPrimary: true,
sortable: true,
sortValue: (f) => jobTitle(f),
render: (f) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
return (
<NameCell
key={renamingId === f.id ? "renaming" : "shown"}
file={f}
renaming={renamingId === f.id}
onPlay={a.play ? () => play(f) : null}
onRenamed={(name) => {
setRenamingId(null);
if (name !== null) actions.rename(f, name);
}}
/>
);
},
},
{
key: "source",
header: t("files.columns.source"),
width: "8rem",
tabletHidden: true,
render: (f) => {
const Icon = sourceIcon(f.source_key);
return (
<Badge>
<Icon />
{sourceLabel(f.source_key, t)}
</Badge>
);
},
},
{
key: "phone",
header: t("files.columns.phone"),
width: "5.5rem",
narrowHidden: true,
// Every library video and sound is phone-safe by construction (backend downloads/playable.py);
// a plain file is not a thing a phone plays.
render: (f) =>
f.asset?.media_kind === "file" ? (
<span className="text-xs text-muted">—</span>
) : (
<Badge title={t("files.phoneOk")}>
<Smartphone />
<span className="text-success">✓</span>
</Badge>
),
},
{
key: "retention",
header: t("files.columns.retention"),
width: "7.5rem",
narrowHidden: true,
render: (f) => <RetentionCell file={f} />,
},
{
key: "size",
header: t("files.columns.size"),
width: "5.5rem",
align: "right",
nowrap: true,
sortable: true,
sortValue: (f) => f.asset?.size_bytes ?? 0,
render: (f) => (
<span className="text-xs tabular-nums text-muted">
{formatBytes(f.asset?.size_bytes, lang)}
</span>
),
},
{
key: "added",
header: t("files.columns.added"),
width: "6.5rem",
narrowHidden: true,
align: "right",
nowrap: true,
sortable: true,
sortValue: (f) => f.shared_at ?? f.created_at ?? "",
render: (f) => {
const at = f.shared_at ?? f.created_at;
return (
<span className="text-xs text-muted" title={formatDateTime(at, lang)}>
{relativeTime(at, lang, t)}
</span>
);
},
},
{
key: "actions",
header: "",
width: "10rem",
align: "right",
cardLabel: false,
render: (f) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
const mine = rowMenu.open && rowMenu.target?.id === f.id;
return (
<span className="flex items-center justify-end gap-0.5">
{!f.shared && a.play ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.copyLink")}
disabled={actions.sharing !== null}
onClick={() => actions.share(f)}
>
<Link />
</IconButton>
) : (
iconSlot
)}
{!f.shared ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.share")}
onClick={() => setShareFile(f)}
>
<Share2 />
</IconButton>
) : (
iconSlot
)}
{f.can_download ? (
<IconLink href={api.fileUrl(f.id)} label={t("downloads.actions.download")} size="md">
<Download />
</IconLink>
) : (
iconSlot
)}
<MoreButton
label={t("files.rowMenu")}
size="md"
expanded={mine}
menuProps={rowMenu.buttonProps(f)}
/>
</span>
);
},
},
];
const body =
filesQ.isError && !filesQ.data ? (
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : filesQ.isLoading ? (
<LoadingState shape="list" />
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(f) => String(f.id)}
persistKey={LS.filesTable}
pageSize={50}
keyboardNav
fixedLayout
rowProps={(f) => rowMenu.triggerProps(f)}
emptyText={
q
? t("files.empty.search", { q })
: t(
shown.kind === "shared"
? "files.empty.shared"
: shown.kind === "folder"
? "files.empty.folder"
: "files.empty.all",
)
}
/>
);
return (
<>
<PageToolbar>
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
{filesQ.data && (
<span className="text-xs tabular-nums text-muted">
{t("files.count", { count: rows.length })}
</span>
)}
{canUpload && (
<Button
variant="outline"
size="sm"
icon={<UploadCloud className="w-4 h-4" />}
onClick={() => setUpload(true)}
className="ml-auto shrink-0"
>
{t("files.actions.upload")}
</Button>
)}
</div>
</PageToolbar>
<div className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
{body}
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
<Suspense fallback={null}>
{upload && (
<UploadDialog
folderId={uploadFolder(shown, folders)}
onClose={() => setUpload(false)}
/>
)}
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
{shareFile && <ShareDialog job={shareFile} onClose={() => setShareFile(null)} />}
{playing && (
<LibraryPlayer
job={playing.file}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
</Suspense>
</div>
</>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./navigationContext";
import {
ALL_FILES,
FilesActionsContext,
FilesStateContext,
parseView,
viewParam,
type FilesActions,
type FilesState,
type FilesView,
} from "./filesContext";
// Owns the Files module's shared state — the header search term and the view the rail picked (every
// file, shared with me, or a folder) — so the header's search box, the FilesRail and the Files page
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
// main.tsx around <App/>.
//
// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per
// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder.
// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// page's to fall back from (it knows the folder list).
function initialView(): FilesView {
return (
parseView(new URLSearchParams(window.location.search).get("folder")) ??
parseView(getAccountRaw(LS.filesView)) ??
ALL_FILES
);
}
export function FilesProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [view, setViewState] = useState<FilesView>(initialView);
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setView = useCallback((next: FilesView) => {
setViewState(next);
setAccountRaw(LS.filesView, viewParam(next));
}, []);
const { page } = useNavigation();
useUrlMirror({ folder: page === "files" ? viewParam(view) : null });
const state = useMemo<FilesState>(() => ({ search, view }), [search, view]);
const actions = useMemo<FilesActions>(() => ({ setSearch, setView }), [setSearch, setView]);
return (
<FilesActionsContext.Provider value={actions}>
<FilesStateContext.Provider value={state}>{children}</FilesStateContext.Provider>
</FilesActionsContext.Provider>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { FolderOpen, HardDrive, Users } from "lucide-react";
import { api, type FileFolder } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatBytes } from "../lib/format";
import { type PanelLayout } from "../lib/panelLayout";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
import FilterRail from "./FilterRail";
import { folderIcon, folderLabel } from "./fileFolders";
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { Pressable } from "./ui/Pressable";
import { type PanelItem } from "./ui/PanelGroups";
// The Files module's left rail: the library's places — every file, the ones shared with me, the
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
// to App level like the other rails (the SidePanel's collapse and customise come with it); the view it
// picks lives in FilesProvider, which the Files page reads.
function Entry({
icon: Icon,
label,
count,
view,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
view: FilesView;
}) {
const { view: current } = useFiles();
const { setView } = useFilesActions();
const active = sameView(view, current);
return (
<Pressable
selectable
layer={!active}
aria-current={active ? "page" : undefined}
onClick={() => setView(view)}
className={clsx(
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-accent/12 font-medium text-accent" : "text-fg",
)}
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
</Pressable>
);
}
function StorageMeter() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Live while something is in flight (a download's bytes land when the worker finishes it), at rest
// otherwise — the old Download Center's meter, moved to where the files are.
const usage = useLiveQuery(qk.storageUsage(), api.filesUsage, {
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
});
const u = usage.data;
if (!u) return null;
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return (
<div className="mt-3 flex flex-col gap-1 px-2 text-xs text-muted" data-testid="storage-meter">
<span className="flex justify-between gap-2">
<span>{t("files.usage.title")}</span>
<span className="tabular-nums">
{formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`}
</span>
</span>
{!u.unlimited && (
<span className="h-1 overflow-hidden rounded-full bg-fg/10">
<span
className={clsx("block h-full rounded-full", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</span>
)}
</div>
);
}
export default function FilesRail({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation();
const { view } = useFiles();
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
const folders = foldersQ.data ?? [];
const total = folders.reduce((n, f) => n + f.count, 0);
// A source folder its files have all left stays on the server; here it shows only while it holds
// something, or while it is the one open (so the page never points at a folder the rail hides).
const shown = (f: FileFolder) => f.count > 0 || sameView(view, { kind: "folder", id: f.id });
const byName = (a: FileFolder, b: FileFolder) =>
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
const own = folders.filter((f) => f.kind === "own").sort(byName);
const failed = foldersQ.isError && !foldersQ.data && (
<Pressable
onClick={() => foldersQ.refetch()}
className="px-2 py-1 text-xs text-danger hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</Pressable>
);
const entry = (f: FileFolder) => (
<Entry
key={f.id}
icon={folderIcon(f)}
label={folderLabel(f, t)}
count={f.count}
view={{ kind: "folder", id: f.id }}
/>
);
const items: PanelItem[] = [
{
id: "library",
title: t("files.rail.library"),
body: (
<div className="flex flex-col gap-0.5">
<Entry
icon={HardDrive}
label={t("files.rail.all")}
count={foldersQ.data ? total : undefined}
view={{ kind: "all" }}
/>
<Entry icon={Users} label={t("files.rail.shared")} view={{ kind: "shared" }} />
</div>
),
},
{
id: "sources",
title: t("files.rail.sources"),
body: (
<div className="flex flex-col gap-0.5">
{failed}
{sources.map(entry)}
{foldersQ.data && sources.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
)}
</div>
),
},
{
id: "own",
title: t("files.rail.own"),
body: (
<div className="flex flex-col gap-0.5">
{failed}
{own.map(entry)}
{foldersQ.data && own.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noOwn")}</span>
)}
</div>
),
},
];
return (
<FilterRail
kind="files"
title={t("files.navLabel")}
icon={<FolderOpen className="w-4 h-4" />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
>
<StorageMeter />
</FilterRail>
);
}
+12
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import { useFiles, useFilesActions } from "./filesContext";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import PlexViewToggle from "./PlexViewToggle";
@@ -37,6 +38,8 @@ const Header = forwardRef<
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { search: channelsSearch } = useChannels();
const { search: filesSearch } = useFiles();
const { setSearch: setFilesSearch } = useFilesActions();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
@@ -77,6 +80,15 @@ const Header = forwardRef<
onGo={blur}
/>
);
} else if (page === "files") {
search = (
<SearchBar
value={filesSearch}
placeholder={t("files.searchPlaceholder")}
onChange={setFilesSearch}
onGo={blur}
/>
);
} else if (page === "playlists") {
search = (
<SearchBar
@@ -53,7 +53,6 @@ const baseJob: DownloadJob = {
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
folder_id: null,
kept: false,
expires_at: null,
@@ -112,7 +111,7 @@ describe("LibraryPlayer", () => {
);
const video = document.body.querySelector("video");
expect(video).not.toBeNull();
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(video!.getAttribute("src")).toBe("/api/files/7/file");
// No <audio> element for a video asset.
expect(document.body.querySelector("audio")).toBeNull();
});
@@ -131,7 +130,7 @@ describe("LibraryPlayer", () => {
);
const audio = document.body.querySelector("audio");
expect(audio).not.toBeNull();
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(audio!.getAttribute("src")).toBe("/api/files/7/file");
expect(document.body.querySelector("video")).toBeNull();
});
@@ -179,7 +178,7 @@ describe("LibraryPlayer", () => {
expect(loaded().currentTime).toBe(0);
});
it("keeps where it stopped: on the server, and on the library row at once", () => {
it("keeps where it stopped: on the server, and on its row in every library list at once", () => {
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
ok: true,
job_id: 7,
@@ -188,7 +187,9 @@ describe("LibraryPlayer", () => {
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
// The file sits in two lists at once: its folder's and the whole library's.
client.setQueryData(qk.files({ folderId: 1 }), [job, { ...job, id: 8 }]);
client.setQueryData(qk.files({}), [job]);
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
@@ -196,8 +197,9 @@ describe("LibraryPlayer", () => {
video.dispatchEvent(new Event("pause"));
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
const rows = client.getQueryData<DownloadJob[]>(qk.files({ folderId: 1 }))!;
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(125);
// Closing the player sends the point the way that survives the page going away.
unmount();
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
@@ -213,10 +215,10 @@ describe("LibraryPlayer", () => {
});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
client.setQueryData(qk.files({}), [{ ...job, position_seconds: 10 }]);
let answer: (jobs: DownloadJob[]) => void = () => {};
const poll = client.fetchQuery({
queryKey: qk.downloads(),
queryKey: qk.files({}),
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
});
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
@@ -226,10 +228,10 @@ describe("LibraryPlayer", () => {
unmount();
answer([{ ...job, position_seconds: 10 }]);
await poll;
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(300);
// Once the point has landed, the listing asks the server again.
const invalidate = vi.spyOn(client, "invalidateQueries");
settled?.();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
});
});
+13 -14
View File
@@ -34,7 +34,7 @@ export default function LibraryPlayer({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
@@ -68,23 +68,22 @@ export default function LibraryPlayer({
artwork: poster ?? null,
});
useMediaKeys(media);
// The library row's bar follows the point at once — both listings this job may sit in — rather
// than after the next refetch. On the way out a listing poll may already be out, having read the
// server before the closing point lands: it is cancelled, or its answer replaced the row's point
// with the older one, Continue opened there, and its first checkpoint wrote it back over the right
// one. Once the point is on the server, the listings are read again.
const lists = [qk.downloads(), qk.downloadsShared()];
// The library row's bar follows the point at once — every library listing this file may sit in
// (each folder, search and the shared view is its own key under `files`) — rather than after the
// next refetch. On the way out a listing read may already be out, having read the server before
// the closing point lands: it is cancelled, or its answer replaced the row's point with the older
// one, Continue opened there, and its first checkpoint wrote it back over the right one. Once the
// point is on the server, the listings are read again.
const lists = { queryKey: qk.files() };
const keepPoint = (position: number, duration: number, leaving: boolean) => {
const at = keptPosition(position, duration);
for (const key of lists) {
if (leaving) void qc.cancelQueries({ queryKey: key, exact: true });
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
);
}
if (leaving) void qc.cancelQueries(lists);
qc.setQueriesData<DownloadJob[]>(lists, (jobs) =>
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
);
if (leaving) {
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
for (const key of lists) void qc.invalidateQueries({ queryKey: key, exact: true });
void qc.invalidateQueries(lists);
});
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
};
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// The local-upload dialog. It's a general file upload (any type, no format list): a picked
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
// file surfaces its name+size, and Upload streams it through api.uploadFile (the XHR-progress
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
// actually reach the ingest call, and that the success toast points at the library. i18n is
// key-passthrough.
@@ -10,11 +10,12 @@ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/re
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
uploadFile: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
// user's bandwidth on it. Generous by default; the cap test below lowers it.
downloadUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
navigateTo: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -25,7 +26,7 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
vi.mock("../lib/api", () => ({
api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage },
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
}));
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
@@ -33,15 +34,15 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
import UploadDialog from "./UploadDialog";
const wrap = (onClose = vi.fn()) => {
const wrap = (onClose = vi.fn(), folderId: number | null = null) => {
const qc = new QueryClient();
return render(
<QueryClientProvider client={qc}>
<UploadDialog onClose={onClose} />
<UploadDialog folderId={folderId} onClose={onClose} />
</QueryClientProvider>,
);
};
@@ -50,10 +51,10 @@ const wrap = (onClose = vi.fn()) => {
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
h.uploadDownload.mockClear();
h.uploadFile.mockClear();
h.notify.mockClear();
h.downloadUsage.mockClear();
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
h.filesUsage.mockClear();
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
});
afterEach(cleanup);
@@ -73,8 +74,8 @@ describe("UploadDialog", () => {
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
const [passedFile, opts] = h.uploadFile.mock.calls[0]!;
expect(passedFile).toBe(f);
expect((opts as { displayName?: string }).displayName).toBe("My notes");
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
@@ -85,7 +86,7 @@ describe("UploadDialog", () => {
it("says so when the server queued the upload for its phone-safe conversion", async () => {
// A video no phone could play as it is (an iPhone's HEVC .mov) comes back `queued`, not `done`:
// "File uploaded" alone would send the user to a Library row that is still converting.
h.uploadDownload.mockResolvedValueOnce({ id: 43, status: "queued" });
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
wrap();
fireEvent.change(fileInput(), {
target: { files: [new File(["x"], "clip.mov", { type: "video/quicktime" })] },
@@ -97,6 +98,27 @@ describe("UploadDialog", () => {
});
});
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
// Done: the file is in Files. Still converting: it is in the download queue until it is done.
wrap(vi.fn(), 5);
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.navigateTo).toHaveBeenLastCalledWith("files");
cleanup();
h.notify.mockClear();
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
wrap();
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "b.mov")] } });
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
});
it("keeps Upload disabled until a file is chosen", () => {
wrap();
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
@@ -118,9 +140,9 @@ describe("UploadDialog", () => {
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
// moves.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP + 1, "big.bin");
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
@@ -139,9 +161,9 @@ describe("UploadDialog", () => {
// the only feedback that a drag is over a live target — and a danger border that outranks the
// drag state reads as "drag-and-drop is off now", sending the user hunting for a control that
// does not exist. The drop would have worked.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP + 1, "big.bin");
const zone = screen.getByText("big.bin").closest("button")!;
expect(zone.className).toContain("border-danger");
@@ -159,9 +181,9 @@ describe("UploadDialog", () => {
// `submit()` changes nothing here, which is why that term is documented as belt and braces
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
// after dropping a bigger file does not start a transfer the server will refuse.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP, "fits.bin");
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
btn.focus();
@@ -170,17 +192,17 @@ describe("UploadDialog", () => {
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
fireEvent.click(btn);
expect(h.uploadDownload).not.toHaveBeenCalled();
expect(h.uploadFile).not.toHaveBeenCalled();
});
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP, "exact.bin");
expect(screen.queryByRole("alert")).toBeNull();
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
});
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
@@ -188,9 +210,9 @@ describe("UploadDialog", () => {
// file, so two over-cap picks leave the node and its text identical — and a live region that
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
// the three other alerts in this app that can repeat.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP + 1, "a.bin");
const first = await screen.findByRole("alert");
pickFile(CAP + 2, "b.bin");
+18 -5
View File
@@ -19,15 +19,24 @@ import { Input } from "./ui/Input";
// Local-file ingest. A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
// lands in the Library as an already-`done` job — or, for media no phone could play as it is (an
// lands in the library (Files) as an already-`done` job — or, for media no phone could play as it is (an
// iPhone's HEVC, a webm, an mkv), as a `queued` job the worker converts first — so we invalidate,
// say which, and point the user there.
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
// skip the whole dialog.
const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
// A done file is in Files; one the worker still converts is in the download queue until it is.
const uploadedPage = (status: string) => (status === "done" ? "files" : "downloads");
export default function UploadDialog({ onClose }: { onClose: () => void }) {
export default function UploadDialog({
folderId = null,
onClose,
}: {
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
folderId?: number | null;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
@@ -46,7 +55,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
// On a default install the one that answers is the edge ceiling (`bodylimit`), whose refusal
// carries no number, so without this the user would be told "too large" and not what the limit is.
// A cap that has not loaded yet blocks nothing — the server still refuses, honestly.
const cap = useQuery({ queryKey: qk.downloadUsage(), queryFn: api.downloadUsage }).data
const cap = useQuery({ queryKey: qk.storageUsage(), queryFn: api.filesUsage }).data
?.upload_max_bytes;
const tooBig = !!file && !!cap && file.size > cap;
const refusalId = useId();
@@ -80,8 +89,9 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
setPct(0);
const displayName = name.trim() || undefined;
try {
const job = await api.uploadDownload(file, {
const job = await api.uploadFile(file, {
displayName,
folderId,
onProgress: setPct,
signal: ac.signal,
});
@@ -89,7 +99,10 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
notify({
level: "success",
message: t(uploadedKey(job.status)),
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
action: {
label: t("downloads.dialog.view"),
onClick: () => navigateTo(uploadedPage(job.status)),
},
});
onClose();
} catch (e) {
+3 -3
View File
@@ -73,7 +73,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [accurate, setAccurate] = useState(true);
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
const aspect =
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
@@ -281,7 +281,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// so another try adds only the rest.
if (done.length) {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
qc.invalidateQueries({ queryKey: qk.storageUsage() });
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is
@@ -299,7 +299,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
},
onSuccess: ({ files }) => {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
qc.invalidateQueries({ queryKey: qk.storageUsage() });
notify({ level: "success", message: t("editor.created", { count: files }) });
onClose();
},
@@ -132,15 +132,15 @@ describe("the upload dialog", () => {
// toast that outlives the test that spawned it and lands as an unexpected error in whichever
// test is running by then. The file is fast enough today that it always lands after the suite,
// which is a coincidence, not a guarantee.
vi.spyOn(api, "downloadUsage").mockResolvedValue({
vi.spyOn(api, "filesUsage").mockResolvedValue({
upload_max_bytes: 1_000_000,
} as Awaited<ReturnType<typeof api.downloadUsage>>);
} as Awaited<ReturnType<typeof api.filesUsage>>);
});
it("names an upload the server refused", async () => {
// A 502 from the proxy raises only the self-clearing connection notice; the next successful poll
// wipes it, and the dialog snapped back with no error and no job in the list.
vi.spyOn(api, "uploadDownload").mockRejectedValue(new HttpError(502, "Bad Gateway"));
vi.spyOn(api, "uploadFile").mockRejectedValue(new HttpError(502, "Bad Gateway"));
mount();
pick();
fireEvent.click(screen.getByText("downloads.upload.submit"));
@@ -150,7 +150,7 @@ describe("the upload dialog", () => {
it("says nothing when the user cancelled it themselves", async () => {
// A cancel is not a failure — and it rejects with the same status 0 a genuine network drop does,
// which is why the difference is a named sentinel (`isAborted`) and not a status check.
const upload = vi.spyOn(api, "uploadDownload").mockRejectedValue(new HttpError(0, "aborted"));
const upload = vi.spyOn(api, "uploadFile").mockRejectedValue(new HttpError(0, "aborted"));
mount();
pick();
fireEvent.click(screen.getByText("downloads.upload.submit"));
+5 -2
View File
@@ -26,11 +26,14 @@ export function useCopyItems() {
);
};
/** The rows for one object. `text` is its title (a video, a show) or its name (a channel, a
* playlist); a row whose value is missing is left out rather than copying an empty string. */
* playlist); a row whose value is missing is left out rather than copying an empty string.
* `linkLabel` names the link row where "Copy link" would be ambiguous (a library file's menu also
* copies its share link). */
return (
text: string | null | undefined,
link: string | null | undefined,
named: Named = "title",
linkLabel?: string,
): ActionMenuItem[] => [
...(text
? [
@@ -47,7 +50,7 @@ export function useCopyItems() {
? [
{
id: "copy-link",
label: t("common.copy.link"),
label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")),
+8 -14
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { invalidateDownloads } from "../lib/downloads";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
@@ -13,12 +13,11 @@ import { Input } from "./ui/Input";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
// editing and the failure-detail window.
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
// All are display-only overrides — the on-disk file is never renamed.
// Edit a library file's details: its channel (name + link) and extra reference links. Display-only
// overrides — the on-disk file is never touched. Its name is renamed in place in the Files list.
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [name, setName] = useState(job.display_name || job.asset?.title || "");
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(
job.display_uploader_url || job.asset?.uploader_url || "",
@@ -32,14 +31,13 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
const save = useMutation({
mutationFn: () =>
api.updateDownloadMeta(job.id, {
display_name: name.trim(),
display_uploader: channel.trim(),
display_uploader_url: channelUrl.trim(),
api.updateFile(job.id, {
uploader: channel.trim(),
uploader_url: channelUrl.trim(),
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.downloads() });
invalidateDownloads(qc);
onClose();
},
meta: { failTitle: "errors.writeFailed.downloadDetails" },
@@ -48,10 +46,6 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
return (
<Modal initialFocus="field" title={t("downloads.edit.title")} onClose={onClose}>
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<Input className="w-full" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
<Input className="w-full" value={channel} onChange={(e) => setChannel(e.target.value)} />
@@ -96,7 +90,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
variant="primary"
size="sm"
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
disabled={save.isPending}
>
{t("downloads.edit.save")}
</Button>
@@ -51,7 +51,6 @@ const baseJob: DownloadJob = {
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
folder_id: null,
kept: false,
expires_at: null,
+10 -50
View File
@@ -2,8 +2,6 @@ import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react";
import clsx from "clsx";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob } from "../lib/api";
import {
formatBytes,
@@ -21,9 +19,9 @@ import { MediaProgress } from "./ui/MediaCard";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
// library, shared, and admin tabs.
// Download-row presentation + the small shared primitives (status pill, icon button, the job's
// picture), split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the
// queue and the admin tab; the Files list borrows the picture.
const STATUS_CLS: Record<string, string> = {
queued: "text-muted",
@@ -60,10 +58,9 @@ export function StatusPill({ job }: { job: DownloadJob }) {
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
);
}
const key = job.expired ? "expired" : job.status;
return (
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
{t(`downloads.status.${key}`)}
{t(`downloads.status.${job.status}`)}
</span>
);
}
@@ -76,7 +73,9 @@ export function StatusPill({ job }: { job: DownloadJob }) {
const THUMB_BOX =
"w-28 @max-[40rem]/dlrow:w-24 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
function Thumb({ job }: { job: DownloadJob }) {
/** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes
* it (the Files list draws a smaller one). */
export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) {
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
@@ -107,7 +106,7 @@ function Thumb({ job }: { job: DownloadJob }) {
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
if (job.asset?.media_kind === "file") {
return (
<div className={`${THUMB_BOX} flex items-center justify-center text-muted`}>
<div className={`${box} flex items-center justify-center text-muted`}>
<File className="w-8 h-8" aria-hidden="true" />
</div>
);
@@ -116,7 +115,7 @@ function Thumb({ job }: { job: DownloadJob }) {
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
return (
<div className={`${THUMB_BOX} relative`}>
<div className={`${box} relative`}>
<MediaProgress pct={pct} />
{src ? (
<img
@@ -262,7 +261,7 @@ export function DownloadRow({
className={`grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-1 [grid-template-areas:'thumb_head_act''thumb_meta_act''thumb_src_act''thumb_prog_act'] @max-[40rem]/dlrow:grid-cols-[auto_minmax(0,1fr)] @max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']`}
>
<div className="[grid-area:thumb]">
<Thumb job={job} />
<JobThumb job={job} />
</div>
<div className="[grid-area:head] min-w-0">
<div
@@ -379,42 +378,3 @@ export function DownloadRow({
</div>
);
}
export function UsageBar() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
// only moves while a job is in flight, so poll fast (2s) then and idle-back-off to 15s otherwise
// (running_jobs counts queued+running — it flips back to 2s on the first poll after a new enqueue).
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, {
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
});
const u = usage.data;
if (!u) return null;
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return (
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
{formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "}
{u.unlimited
? t("downloads.usage.unlimited")
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
</span>
</div>
{!u.unlimited && (
<div className="h-2 rounded-full bg-surface overflow-hidden">
<div
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import type { FileFolder } from "../lib/api";
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
// (`youtube`, `plex`, `upload`, `hub`, `site:<host>`, see backend downloads/folders.py) and leaves
// its name to the client, in the reader's language; an own folder carries the name its user gave it.
const SITE_PREFIX = "site:";
type T = (key: string) => string;
/** The name of a source: a site is its host, the rest are words of the UI. */
export function sourceLabel(key: string, t: T): string {
if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length);
if (key === "youtube" || key === "plex" || key === "upload" || key === "hub")
return t(`files.source.${key}`);
return key;
}
export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
if (key === "youtube") return YouTubeIcon;
if (key === "plex") return Film;
if (key === "upload") return Upload;
if (key === "hub") return Server;
return Globe;
}
export function folderLabel(folder: FileFolder, t: T): string {
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
}
export function folderIcon(folder: FileFolder): LucideIcon | typeof YouTubeIcon {
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
}
+47
View File
@@ -0,0 +1,47 @@
import { createContext, useContext } from "react";
// The Files module's contexts + hooks, split out of FilesProvider so that file exports only
// components (Fast Refresh).
/** What the Files page lists: every file of mine, the ones shared with me, or one folder's. */
export type FilesView = { kind: "all" } | { kind: "shared" } | { kind: "folder"; id: number };
export const ALL_FILES: FilesView = { kind: "all" };
/** The view as one string — the `?folder=` bookmark and the remembered view share it. */
export function viewParam(view: FilesView): string {
return view.kind === "folder" ? String(view.id) : view.kind;
}
/** The view a stored or bookmarked string names; anything else is the whole library. */
export function parseView(raw: string | null | undefined): FilesView | null {
if (raw === "all") return ALL_FILES;
if (raw === "shared") return { kind: "shared" };
const id = raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
return id !== null && Number.isSafeInteger(id) ? { kind: "folder", id } : null;
}
export const sameView = (a: FilesView, b: FilesView): boolean => viewParam(a) === viewParam(b);
export type FilesState = {
search: string;
view: FilesView;
};
export interface FilesActions {
setSearch: (q: string) => void;
setView: (view: FilesView) => void;
}
export const FilesStateContext = createContext<FilesState>({ search: "", view: ALL_FILES });
export const FilesActionsContext = createContext<FilesActions>({
setSearch: () => {},
setView: () => {},
});
export function useFiles(): FilesState {
return useContext(FilesStateContext);
}
export function useFilesActions(): FilesActions {
return useContext(FilesActionsContext);
}
+8 -3
View File
@@ -26,6 +26,8 @@ const Playlists = lazyWithPreload(() => import("./Playlists"));
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
const Files = lazyWithPreload(() => import("./Files"));
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
const Stats = lazyWithPreload(() => import("./Stats"));
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
@@ -54,6 +56,7 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "100rem" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
files: { Component: Files, maxWidth: "80rem" },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
@@ -73,8 +76,9 @@ type RailProps = {
onToggleCollapse: () => void;
};
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own).
type RailCollapseKey = "filter" | "playlists";
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
// have their own).
type RailCollapseKey = "filter" | "playlists" | "files";
export type RailDef = {
Rail: ComponentType<RailProps>;
@@ -83,11 +87,12 @@ export type RailDef = {
collapse: RailCollapseKey;
};
// page id → its side rail (only the three pages that have one). App renders it in the rail slot.
// page id → its side rail (only the pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
files: { Rail: FilesRail, panelId: "files", collapse: "files" },
};
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
@@ -28,6 +28,7 @@ export function loadInitialPage(): Page {
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
if (params.has("folder")) return "files";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → Home, the surface whose whole job is to answer "what should I watch" before
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
@@ -17,6 +17,7 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"FilesRail.tsx": [1, "the load-error retry"],
"Home.tsx": [
2,
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
+12 -219
View File
@@ -1,40 +1,21 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Ban,
Download,
Link2,
Pause,
Pencil,
Play,
PlayCircle,
RefreshCw,
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { invalidateDownloads } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A download's ONE action list: the row's "…", its right-click, long-press and Shift+F10,
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
// managing the shares, re-download, the copies and the destructive rows — lives here.
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
// sight; the copies and Cancel live here. A finished download is a library file: its actions are
// the Files page's (useFileActions).
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
export type JobOp = "pause" | "resume" | "cancel";
// The Cancel confirmation of a queued job; an upload's names what it deletes.
const CANCEL_DOWNLOAD = {
@@ -63,27 +44,7 @@ const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
};
// Deleting a download may leave its shared cache file for other holders. An upload and a clip are
// their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good.
const DELETE_COPY: Record<DownloadJobKind, { title: string; body: string }> = {
download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" },
upload: {
title: "downloads.confirm.deleteUploadTitle",
body: "downloads.confirm.deleteUploadBody",
},
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
};
export function useDownloadActions({
onPlay,
onEdit,
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
}) {
export function useDownloadActions() {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
@@ -95,83 +56,16 @@ export function useDownloadActions({
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
: api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc),
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share: ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-choosing reuses the existing link; a per-job in-flight guard also stops a
// double pick from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, "Manage sharing".
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
await quickShareLink(qc, job, t).finally(() => setSharingId(null));
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
// A started item continues, and can also start over — the card menus' words and glyph (C5).
const play = (job: DownloadJob): ActionMenuItem[] => {
const started = (job.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(job, true),
},
]
: []),
];
};
// The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.downloadFileUrl(job.id);
a.click();
},
});
const edit = (job: DownloadJob): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(job),
});
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
function queueItems(job: DownloadJob): ActionMenuItem[] {
@@ -212,112 +106,11 @@ export function useDownloadActions({
);
}
function libraryItems(job: DownloadJob): ActionMenuItem[] {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return menuGroups(
// The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file.
[
...(a.play ? play(job) : []),
{
id: "share",
label: t("downloads.actions.share"),
icon: icon(Share2),
disabled: sharingId !== null,
onSelect: () => void quickShare(job),
},
save(job),
],
[
...(a.edit ? [edit(job)] : []),
...(a.editDetails
? [
{
id: "edit-details",
label: t("downloads.actions.editDetails"),
icon: icon(Pencil),
onSelect: () => onEditDetails(job),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
),
},
]
: []),
],
[
{
id: "share-manage",
label: t("downloads.actions.shareManage"),
icon: icon(Link2),
onSelect: () => onManageShares(job),
},
...copies(job),
],
[
{
id: "delete",
label: t("downloads.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () => {
const copy = DELETE_COPY[job.job_kind ?? "download"];
void confirmThen(t(copy.title), t(copy.body), t("downloads.actions.delete"), () =>
act.mutate({ id: job.id, op: "delete" }),
);
},
},
],
);
}
function sharedItems(job: DownloadJob): ActionMenuItem[] {
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return menuGroups(
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [],
copies(job),
[
{
id: "remove-shared",
label: t("downloads.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
),
},
],
);
}
/** The job's menu rows on the tab it is listed on. */
const items = (job: DownloadJob, tab: DownloadTab): ActionMenuItem[] =>
tab === "queue" ? queueItems(job) : tab === "done" ? libraryItems(job) : sharedItems(job);
/** The job's menu rows. */
const items = (job: DownloadJob): ActionMenuItem[] => queueItems(job);
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
/** The library row's visible Share: the same one-click link copy as its menu row. */
const share = (job: DownloadJob) => void quickShare(job);
return { items, run, share };
return { items, run };
}
+303
View File
@@ -0,0 +1,303 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Download,
FolderInput,
Info,
Link,
Pencil,
Pin,
PinOff,
PlayCircle,
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { folderLabel } from "./fileFolders";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every
// action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones
// shared with me get different sets: a shared file is the owner's, so it is played, saved, cut or
// dropped from my list, never renamed, moved or deleted.
const DELETE_COPY: Record<DownloadJobKind, { title: string; body: string }> = {
download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" },
upload: {
title: "downloads.confirm.deleteUploadTitle",
body: "downloads.confirm.deleteUploadBody",
},
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
};
type Patch = { name?: string | null; folder_id?: number; kept?: boolean };
// Which failure title a PATCH speaks under: what it was asked to change.
const patchFail = (p: Patch) =>
"folder_id" in p
? "errors.writeFailed.file.move"
: "kept" in p
? "errors.writeFailed.file.keep"
: "errors.writeFailed.file.rename";
export function useFileActions({
folders,
onPlay,
onEdit,
onRename,
onEditDetails,
onManageShares,
}: {
/** The caller's folders — the Move submenu's places. */
folders: readonly FileFolder[];
onPlay: (file: LibraryFile, fromStart?: boolean) => void;
onEdit: (file: LibraryFile) => void;
onRename: (file: LibraryFile) => void;
onEditDetails: (file: LibraryFile) => void;
onManageShares: (file: LibraryFile) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const copy = useCopyItems();
const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
});
const remove = useMutation({
mutationFn: (id: number) => api.deleteFile(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.file.delete" },
});
const redownload = useMutation({
mutationFn: (id: number) => api.redownloadDownload(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.job.redownload" },
});
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedFile(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (file: LibraryFile) => {
if (sharingId !== null) return;
setSharingId(file.id);
await quickShareLink(qc, file, t).finally(() => setSharingId(null));
};
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
const play = (file: LibraryFile): ActionMenuItem[] => {
const started = (file.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(file),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(file, true),
},
]
: []),
];
};
const save = (file: LibraryFile): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.fileUrl(file.id);
a.click();
},
});
const copyLink = (file: LibraryFile): ActionMenuItem => ({
id: "share-link",
label: t("files.actions.copyLink"),
icon: icon(Link),
disabled: sharingId !== null,
onSelect: () => void quickShare(file),
});
const edit = (file: LibraryFile): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(file),
});
// "Copy link" is the share link here (the library's daily errand); the page it came from is the
// source link, named so.
const copies = (file: LibraryFile) =>
copy(jobTitle(file), file.source_url, "title", t("files.actions.copySourceLink"));
// Move: every own folder (the one it is in, ticked), then home — its own source folder — when it
// has left it. The server refuses any other place (`folders.move`), so none is offered.
const moveTo = (file: LibraryFile): ActionMenuItem => {
const own = folders.filter((f) => f.kind === "own");
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
const items: ActionMenuItem[] = own.map((f) => ({
id: `move-${f.id}`,
label: folderLabel(f, t),
check: { kind: "one", checked: f.id === file.folder_id },
onSelect: to(f),
}));
if (home && home.id !== file.folder_id)
items.push({
id: "move-home",
label: t("files.actions.moveHome", { folder: folderLabel(home, t) }),
separatorBefore: items.length > 0,
onSelect: to(home),
});
if (items.length === 0)
items.push({
id: "move-none",
label: t("files.actions.noOwnFolders"),
disabled: true,
onSelect: () => {},
});
return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items };
};
function mineItems(file: LibraryFile): ActionMenuItem[] {
const a = libraryRowActions(file);
return menuGroups(
// The row's icons first, in the row's order.
[
...(a.play ? play(file) : []),
...(a.play ? [copyLink(file)] : []),
{
id: "share",
label: t("files.actions.share"),
icon: icon(Share2),
onSelect: () => onManageShares(file),
},
save(file),
],
[
...(a.edit ? [edit(file)] : []),
{
id: "rename",
label: t("files.actions.rename"),
icon: icon(Pencil),
onSelect: () => onRename(file),
},
moveTo(file),
{
id: "keep",
label: t(file.kept ? "files.actions.unkeep" : "files.actions.keep"),
icon: icon(file.kept ? PinOff : Pin),
onSelect: () => update.mutate({ id: file.id, patch: { kept: !file.kept } }),
},
],
[
...(a.editDetails
? [
{
id: "details",
label: t("files.actions.details"),
icon: icon(Info),
onSelect: () => onEditDetails(file),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => redownload.mutate(file.id),
),
},
]
: []),
...copies(file),
],
[
{
id: "delete",
label: t("downloads.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () => {
const c = DELETE_COPY[file.job_kind ?? "download"];
void confirmThen(t(c.title), t(c.body), t("downloads.actions.delete"), () =>
remove.mutate(file.id),
);
},
},
],
);
}
function sharedItems(file: LibraryFile): ActionMenuItem[] {
const a = sharedRowActions(file);
return menuGroups(
[...(a.play ? play(file) : []), ...(a.save ? [save(file)] : [])],
a.edit ? [edit(file)] : [],
copies(file),
[
{
id: "remove-shared",
label: t("downloads.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(file.id),
),
},
],
);
}
/** The file's menu rows. */
const items = (file: LibraryFile): ActionMenuItem[] =>
file.shared ? sharedItems(file) : mineItems(file);
/** The row's Copy-link icon: the menu's row, one click. */
const share = (file: LibraryFile) => void quickShare(file);
/** Save the inline rename: an empty name gives the file its own title back. */
const rename = (file: LibraryFile, name: string) => {
const next = name.trim();
if (next === (file.display_name ?? "")) return;
update.mutate({ id: file.id, patch: { name: next || null } });
};
return { items, share, rename, sharing: sharingId };
}
+1 -15
View File
@@ -41,8 +41,6 @@
},
"tabs": {
"queue": "Queue",
"done": "Library",
"shared": "Shared with me",
"system": "System"
},
"status": {
@@ -51,8 +49,7 @@
"paused": "Paused",
"done": "Ready",
"error": "Failed",
"canceled": "Canceled",
"expired": "Expired"
"canceled": "Canceled"
},
"phase": {
"video": "Downloading video",
@@ -75,9 +72,6 @@
"download": "Save to my device",
"play": "Play",
"rename": "Rename",
"editDetails": "Edit details…",
"share": "Share (copy link)",
"shareManage": "Manage sharing…",
"edit": "Edit / trim…",
"redownload": "Re-download (rebuild file)",
"removeShared": "Remove from my list"
@@ -87,21 +81,13 @@
},
"empty": {
"queue": "Nothing in the queue.",
"done": "No downloads yet.",
"shared": "Nothing has been shared with you.",
"admin": "No downloads on this instance yet."
},
"usage": {
"title": "Your storage",
"items": "{{used}} / {{max}} downloads",
"unlimited": "Unlimited"
},
"source": {
"open": "Open the original page in a new tab"
},
"edit": {
"title": "Edit details",
"name": "Title",
"channel": "Channel",
"channelUrl": "Channel link",
"extraLinks": "Extra links",
+7 -1
View File
@@ -97,7 +97,13 @@
"videoReset": "Couldn't reset the video",
"watchLaterAdd": "Couldn't add it to Watch later",
"watchLaterRemove": "Couldn't remove it from Watch later",
"clearSearch": "Couldn't clear the search results"
"clearSearch": "Couldn't clear the search results",
"file": {
"rename": "Couldn't rename the file",
"move": "Couldn't move the file",
"keep": "Couldn't change whether the file is kept",
"delete": "Couldn't delete the file"
}
},
"ok": "OK",
"offline": {
+60
View File
@@ -0,0 +1,60 @@
{
"navLabel": "Files",
"searchPlaceholder": "Search files…",
"count_one": "{{count}} item",
"count_other": "{{count}} items",
"rowMenu": "File actions",
"phoneOk": "Plays on a phone too",
"rail": {
"library": "Library",
"all": "All files",
"shared": "Shared with me",
"sources": "Sources",
"own": "My folders",
"noSources": "Nothing downloaded yet",
"noOwn": "No folders of your own yet"
},
"source": {
"youtube": "YouTube",
"plex": "Plex clips",
"upload": "Uploads",
"hub": "Hub"
},
"usage": {
"title": "Storage"
},
"columns": {
"name": "Name",
"source": "Source",
"phone": "Phone",
"retention": "Keeps",
"size": "Size",
"added": "Added"
},
"retention": {
"kept": "Kept",
"never": "Kept until you delete it",
"until": "Goes on {{when}}",
"days_one": "{{count}} day",
"days_other": "{{count}} days"
},
"empty": {
"all": "No files yet.",
"folder": "This folder is empty.",
"shared": "Nothing has been shared with you.",
"search": "No file matches “{{q}}”."
},
"actions": {
"copyLink": "Copy link",
"copySourceLink": "Copy source link",
"share": "Share…",
"rename": "Rename",
"move": "Move to",
"moveHome": "Back to {{folder}}",
"noOwnFolders": "No folders of your own yet",
"keep": "Keep",
"unkeep": "Stop keeping",
"details": "Details…",
"upload": "Upload"
}
}
+1
View File
@@ -27,6 +27,7 @@
"goPlaylists": "Go to Playlists",
"goPlex": "Go to Plex",
"goNotifications": "Go to Notifications",
"goFiles": "Go to Files",
"goDownloads": "Go to Downloads",
"goStats": "Go to Stats",
"goSettings": "Go to Settings",
+1
View File
@@ -13,6 +13,7 @@
"playlists": "Playlists",
"plex": "Plex",
"notifications": "Notifications",
"files": "Files",
"downloads": "Downloads",
"stats": "Stats",
"scheduler": "Scheduler",
+1 -15
View File
@@ -41,8 +41,6 @@
},
"tabs": {
"queue": "Sor",
"done": "Könyvtár",
"shared": "Velem megosztva",
"system": "Rendszer"
},
"status": {
@@ -51,8 +49,7 @@
"paused": "Szüneteltetve",
"done": "Kész",
"error": "Sikertelen",
"canceled": "Megszakítva",
"expired": "Lejárt"
"canceled": "Megszakítva"
},
"phase": {
"video": "Videósáv letöltése",
@@ -75,9 +72,6 @@
"download": "Mentés a gépemre",
"play": "Lejátszás",
"rename": "Átnevezés",
"editDetails": "Adatok szerkesztése…",
"share": "Megosztás (link másolása)",
"shareManage": "Megosztás kezelése…",
"edit": "Szerkesztés / vágás…",
"redownload": "Újraletöltés (fájl újraépítése)",
"removeShared": "Eltávolítás a listámból"
@@ -87,21 +81,13 @@
},
"empty": {
"queue": "A sor üres.",
"done": "Még nincs letöltés.",
"shared": "Még nem osztottak meg veled semmit.",
"admin": "Ezen a példányon még nincs letöltés."
},
"usage": {
"title": "Tárhelyed",
"items": "{{used}} / {{max}} letöltés",
"unlimited": "Korlátlan"
},
"source": {
"open": "Eredeti oldal megnyitása új lapon"
},
"edit": {
"title": "Adatok szerkesztése",
"name": "Cím",
"channel": "Csatorna",
"channelUrl": "Csatorna link",
"extraLinks": "További linkek",
+7 -1
View File
@@ -97,7 +97,13 @@
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
"clearSearch": "Nem sikerült törölni a keresési találatokat"
"clearSearch": "Nem sikerült törölni a keresési találatokat",
"file": {
"rename": "Nem sikerült átnevezni a fájlt",
"move": "Nem sikerült áthelyezni a fájlt",
"keep": "Nem sikerült módosítani a fájl megtartását",
"delete": "Nem sikerült törölni a fájlt"
}
},
"ok": "OK",
"offline": {
+60
View File
@@ -0,0 +1,60 @@
{
"navLabel": "Fájlok",
"searchPlaceholder": "Keresés a fájlok között…",
"count_one": "{{count}} elem",
"count_other": "{{count}} elem",
"rowMenu": "Fájl műveletei",
"phoneOk": "Telefonon is lejátszható",
"rail": {
"library": "Könyvtár",
"all": "Minden fájl",
"shared": "Megosztva velem",
"sources": "Források",
"own": "Saját mappák",
"noSources": "Még nincs letöltött fájl",
"noOwn": "Még nincs saját mappád"
},
"source": {
"youtube": "YouTube",
"plex": "Plex klipek",
"upload": "Feltöltések",
"hub": "Hub"
},
"usage": {
"title": "Tárhely"
},
"columns": {
"name": "Név",
"source": "Forrás",
"phone": "Telefon",
"retention": "Megőrzés",
"size": "Méret",
"added": "Hozzáadva"
},
"retention": {
"kept": "Megtartva",
"never": "Addig marad, amíg nem törlöd",
"until": "Törlődik: {{when}}",
"days_one": "{{count}} nap",
"days_other": "{{count}} nap"
},
"empty": {
"all": "Még nincs fájlod.",
"folder": "Ez a mappa üres.",
"shared": "Veled még nem osztottak meg semmit.",
"search": "Egy fájl sem illik erre: „{{q}}”."
},
"actions": {
"copyLink": "Link másolása",
"copySourceLink": "Forrás linkjének másolása",
"share": "Megosztás…",
"rename": "Átnevezés",
"move": "Áthelyezés",
"moveHome": "Vissza ide: {{folder}}",
"noOwnFolders": "Még nincs saját mappád",
"keep": "Megtartom",
"unkeep": "Megtartás visszavonása",
"details": "Részletek…",
"upload": "Feltöltés"
}
}
+1
View File
@@ -27,6 +27,7 @@
"goPlaylists": "Ugrás: Lejátszási listák",
"goPlex": "Ugrás: Plex",
"goNotifications": "Ugrás: Értesítések",
"goFiles": "Ugrás a Fájlokhoz",
"goDownloads": "Ugrás: Letöltések",
"goStats": "Ugrás: Statisztika",
"goSettings": "Ugrás: Beállítások",
+1
View File
@@ -13,6 +13,7 @@
"playlists": "Listák",
"plex": "Plex",
"notifications": "Értesítések",
"files": "Fájlok",
"downloads": "Letöltések",
"stats": "Statisztika",
"scheduler": "Ütemező",
+3
View File
@@ -60,6 +60,8 @@ export {
} from "./api/plex";
import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
import { filesApi } from "./api/files";
export type * from "./api/files";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
export type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
@@ -80,6 +82,7 @@ export const api = {
...adminApi,
...plexApi,
...downloadsApi,
...filesApi,
...diagnosticsApi,
...debugSessionsApi,
};
+15 -4
View File
@@ -24,6 +24,7 @@ vi.mock("../activeAccount", () => ({
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
import { filesApi } from "./files";
import {
beacon,
req,
@@ -48,10 +49,13 @@ class FakeXHR {
onabort: (() => void) | null = null;
sent = false;
body: unknown = null;
url = "";
constructor() {
FakeXHR.last = this;
}
open() {}
open(_method: string, url: string) {
this.url = url;
}
setRequestHeader(k: string, v: string) {
// A real XHR COMBINES values for a name it already has, case-insensitively, rather than
// overwriting — so a plain assignment here would hide exactly the duplicate-header bug the
@@ -470,10 +474,10 @@ describe("the debug session a tab records into", () => {
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
// backend's own test only sees the shape the client actually sends.
describe("uploadDownload's wire shape", () => {
describe("uploadFile's wire shape", () => {
it("sends the File as the raw body, with the names as percent-encoded headers", () => {
const file = new File(["abc"], "nyaralás videó.mp4", { type: "video/mp4" });
void downloadsApi.uploadDownload(file, { displayName: "Nyaralás 2026" });
void filesApi.uploadFile(file, { displayName: "Nyaralás 2026" });
const x = FakeXHR.last;
expect(x.body).toBe(file);
expect(x.body).not.toBeInstanceOf(FormData);
@@ -484,8 +488,15 @@ describe("uploadDownload's wire shape", () => {
});
it("omits the display-name header when there is no display name", () => {
void downloadsApi.uploadDownload(new File(["abc"], "clip.mp4"));
void filesApi.uploadFile(new File(["abc"], "clip.mp4"));
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBeUndefined();
});
it("names the folder it goes into, when it has one", () => {
void filesApi.uploadFile(new File(["abc"], "clip.mp4"), { folderId: 7 });
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBe("7");
expect(FakeXHR.last.url).toBe("/api/files/upload");
});
});
+1 -64
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { beacon, req, uploadReq, getActiveAccount } from "./core";
import { beacon, req, getActiveAccount } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -105,7 +105,6 @@ export interface DownloadJob {
// An edit's recipe, or a clip's spec (its request without the name) — by job_kind.
edit_spec?: EditSpec | Omit<ClipRequest, "display_name"> | null;
can_download: boolean;
expired: boolean;
// The Files library (backend downloads/folders): its folder, whether the user keeps it past the
// folder's retention, and when it goes (null = never).
folder_id: number | null;
@@ -142,20 +141,6 @@ export interface ShareLink {
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
export const DISPLAY_NAME_MAX = 255;
export interface DownloadUsage {
footprint_bytes: number;
active_jobs: number;
running_jobs: number;
max_bytes: number;
max_concurrent: number;
max_jobs: number;
unlimited: boolean;
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
upload_max_bytes: number;
}
export interface AdminStorage {
files_on_disk: number;
total_bytes: number;
@@ -214,29 +199,6 @@ export const downloadsApi = {
spec?: DownloadSpec;
display_name?: string;
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
// Ingest a local file into the library. Sent via XHR rather than `req` so the dialog can
// show upload progress; any file type is accepted (media gets the full function set, a generic
// file is download-only). Returns the job the ingest creates: `done`, or `queued` while the
// worker converts media no phone could play as it is (`downloads/upload.py`).
// The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is
// a DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency
// runs — so with the old shape an unauthenticated caller could make the server write the whole
// per-file allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`;
// the server percent-decodes both. Content-Type is left to the browser (the File's own type).
uploadDownload: (
file: File,
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
): Promise<DownloadJob> => {
const headers: Record<string, string> = {
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
};
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
return uploadReq<DownloadJob>("/api/downloads/upload", file, {
onProgress: opts.onProgress,
signal: opts.signal,
headers,
});
},
enqueueEdit: (body: {
source_job_id: number;
edit_spec: EditSpec;
@@ -250,11 +212,6 @@ export const downloadsApi = {
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
req(`/api/downloads/${id}/storyboard`),
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
// Remove a shared-with-me item from your list (deletes only your share grant, not the file).
removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
pauseDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
@@ -266,18 +223,6 @@ export const downloadsApi = {
// deletes the old file, and rebuilds it under the current format/naming rules.
redownloadDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
// Update a download's display metadata (title / channel name+link / extra reference URLs).
updateDownloadMeta: (
id: number,
meta: {
display_name?: string;
display_uploader?: string;
display_uploader_url?: string;
extra_links?: string[];
},
): Promise<DownloadJob> =>
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
unshareDownload: (id: number, email: string) =>
@@ -330,14 +275,6 @@ export const downloadsApi = {
},
onSettled,
),
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
// account via ?account= (the server honours it only for an account in this browser's wallet) —
// otherwise the download resolves to the session-default account and 404s for a per-tab
// account's file.
downloadFileUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
},
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
storyboardImageUrl: (id: number): string => {
const a = getActiveAccount();
+1
View File
@@ -32,6 +32,7 @@ const SLICE_MODULES = [
["adminApi", () => import("./admin")],
["plexApi", () => import("./plex")],
["downloadsApi", () => import("./downloads")],
["filesApi", () => import("./files")],
["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
] as const;
+123
View File
@@ -0,0 +1,123 @@
// The Files library: a user's done files, the folders they sit in, and the file itself
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
import { getActiveAccount, req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
export type FolderKind = "source" | "own";
export interface FileFolder {
id: number;
kind: FolderKind;
// A source folder's origin — `youtube`, `plex`, `upload`, `site:<host>` — which the client names
// in the reader's language; null on an own folder.
source_key: string | null;
name: string | null; // an own folder's name; null on a source folder
retention_days: number | null; // null = kept until deleted
count: number; // its done files
size_bytes: number;
created_at: string | null;
}
export type FileSort = "added" | "name" | "size";
export type FileOrder = "asc" | "desc";
export interface FileListParams {
folderId?: number | null; // one folder's files; absent = every file
q?: string;
sort?: FileSort;
order?: FileOrder;
shared?: boolean; // the files others shared with me instead of mine
}
export interface LibraryFile extends DownloadJob {
// When it was shared with me (the "added" of a shared file); null on my own.
shared_at?: string | null;
// Where it came from — its source folder's key (`youtube`, `site:<host>`, …), wherever it sits now.
source_key: string;
}
// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
export interface FilePatch {
name?: string | null;
uploader?: string | null;
uploader_url?: string | null;
extra_links?: string[];
folder_id?: number;
kept?: boolean;
}
export interface StorageUsage {
footprint_bytes: number;
active_jobs: number;
running_jobs: number;
max_bytes: number;
max_concurrent: number;
max_jobs: number;
unlimited: boolean;
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
upload_max_bytes: number;
}
function listQuery(p: FileListParams): string {
const q = new URLSearchParams();
if (p.shared) q.set("scope", "shared");
else if (p.folderId != null) q.set("folder_id", String(p.folderId));
if (p.q?.trim()) q.set("q", p.q.trim());
if (p.sort) q.set("sort", p.sort);
if (p.order) q.set("order", p.order);
const s = q.toString();
return s ? `?${s}` : "";
}
export const filesApi = {
fileFolders: (): Promise<FileFolder[]> => req("/api/files/folders"),
files: (p: FileListParams = {}): Promise<LibraryFile[]> => req(`/api/files${listQuery(p)}`),
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
req(`/api/files/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteFile: (id: number): Promise<{ ok: true; deleted: number }> =>
req(`/api/files/${id}`, { method: "DELETE" }),
// Drop a file someone shared with me from my list (only my grant goes, not their file).
removeSharedFile: (id: number): Promise<{ ok: true; removed: number }> =>
req(`/api/files/shared/${id}`, { method: "DELETE" }),
// Put a local file in the library. Sent via XHR rather than `req` so the dialog can show upload
// progress; any file type is accepted (media gets the full function set, a generic file is
// download-only). Returns the job the ingest creates: `done`, or `queued` while the worker
// converts media no phone could play as it is (`downloads/upload.py`).
// The body IS the file, and the names ride as headers. Not a style choice: a multipart part is a
// DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency runs
// — so with that shape an unauthenticated caller could make the server write the whole per-file
// allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`; the server
// percent-decodes them. Content-Type is left to the browser (the File's own type). No folder =
// the Uploads folder.
uploadFile: (
file: File,
opts: {
displayName?: string;
folderId?: number | null;
onProgress?: (pct: number) => void;
signal?: AbortSignal;
} = {},
): Promise<DownloadJob> => {
const headers: Record<string, string> = {
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
};
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
if (opts.folderId != null) headers["X-Upload-Folder"] = String(opts.folderId);
return uploadReq<DownloadJob>("/api/files/upload", file, {
onProgress: opts.onProgress,
signal: opts.signal,
headers,
});
},
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
// file resolves to the session-default account and 404s for a per-tab account's file.
fileUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
};
+6 -3
View File
@@ -5,12 +5,15 @@ import { copyText } from "./clipboard";
import { notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
// The download queue, the per-card download index, and the storage-usage meter all move together
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
// The download queue, the per-card download index, the library (its listings and folder counts) and
// the storage meter all move together whenever a job is enqueued, deleted, finishes, or a file is
// renamed or moved — invalidate them as one unit.
export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
qc.invalidateQueries({ queryKey: qk.files() });
qc.invalidateQueries({ queryKey: qk.fileFolders() });
qc.invalidateQueries({ queryKey: qk.storageUsage() });
}
// One-click share: the row's Share button turns "share this" into a single action.
+1
View File
@@ -117,6 +117,7 @@ export const SHORTCUTS = [
labelKey: "keys.goNotifications",
page: "notifications",
},
{ id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" },
{
id: "goDownloads",
scope: "page",
+7
View File
@@ -8,6 +8,7 @@ import {
ListVideo,
ScrollText,
FileSearch,
FolderOpen,
Settings,
SlidersHorizontal,
Tv,
@@ -88,6 +89,12 @@ const MODULES: readonly ModuleDef[] = [
icon: Bell,
hasBadge: true,
},
{
id: "files",
labelKey: "files.navLabel",
railKey: "nav.rail.files",
icon: FolderOpen,
},
{
id: "downloads",
labelKey: "downloads.navLabel",
+1
View File
@@ -11,6 +11,7 @@ const EXPECTED: Record<Page, string> = {
channels: "header.channelManager",
playlists: "header.account.playlists",
notifications: "inbox.navLabel",
files: "files.navLabel",
downloads: "downloads.navLabel",
stats: "header.usageStats",
plex: "plex.navLabel",
+9 -3
View File
@@ -1,10 +1,10 @@
// Per-user customization of a side panel's group cards: their order, which are collapsed, and
// which are hidden — the same "customize" affordance the feed filter rail had, generalized so
// every panel (feed / plex / playlists) has its own saved layout. Persisted to localStorage and
// every panel (feed / plex / playlists / files) has its own saved layout. Persisted to localStorage and
// the server-side `preferences` blob (schemaless), so it follows the account across devices.
import { LS, readAccount, writeAccount } from "./storage";
export type PanelId = "feed" | "plex" | "playlists";
export type PanelId = "feed" | "plex" | "playlists" | "files";
export interface PanelLayout {
order: string[];
@@ -30,6 +30,7 @@ const PANEL_ORDER: Record<PanelId, string[]> = {
"contentrating",
],
playlists: ["manage", "list"],
files: ["library", "sources", "own"],
};
// localStorage key per panel (feed keeps its original key for backward compatibility).
@@ -37,13 +38,18 @@ const LS_KEY: Record<PanelId, string> = {
feed: LS.sidebarLayout,
plex: LS.plexLayout,
playlists: LS.playlistsLayout,
files: LS.filesLayout,
};
// Key on the server `preferences` blob per panel (feed keeps its original key).
export const PREF_KEY: Record<PanelId, "sidebarLayout" | "plexLayout" | "playlistsLayout"> = {
export const PREF_KEY: Record<
PanelId,
"sidebarLayout" | "plexLayout" | "playlistsLayout" | "filesLayout"
> = {
feed: "sidebarLayout",
plex: "plexLayout",
playlists: "playlistsLayout",
files: "filesLayout",
};
export function defaultLayout(panel: PanelId): PanelLayout {
+6 -3
View File
@@ -1,4 +1,4 @@
import type { FeedFilters } from "./api";
import type { FeedFilters, FileListParams } from "./api";
// Single source of truth for React Query keys. Every key root lives here exactly once, so a typo
// at one of the ~200 call sites can no longer mint an orphan key that silently never invalidates
@@ -64,11 +64,14 @@ export const qk = {
// --- sharing ---
shareRecipients: () => ["share-recipients"] as const,
// --- files (the library) ---
// Every listing's key starts with "files", so one invalidation reaches each folder, search and sort.
files: (p?: FileListParams) => (p ? (["files", p] as const) : (["files"] as const)),
fileFolders: () => ["file-folders"] as const,
storageUsage: () => ["storage-usage"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,
downloadsShared: () => ["downloads-shared"] as const,
downloadIndex: () => ["download-index"] as const,
downloadUsage: () => ["download-usage"] as const,
downloadProfiles: () => ["download-profiles"] as const,
downloadCredentials: () => ["download-credentials"] as const,
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
+6
View File
@@ -10,6 +10,12 @@ export const LS = {
plexLayout: "siftlode.plexLayout",
playlistsLayout: "siftlode.playlistsLayout",
playlistsCollapsed: "siftlode.playlistsCollapsed",
filesLayout: "siftlode.filesLayout",
filesCollapsed: "siftlode.filesCollapsed",
// The Files view last open (every file, shared with me, or a folder id), per-account.
filesView: "siftlode.filesView",
// The Files table's sort and rows-per-page (DataTable's persist blob).
filesTable: "siftlode.filesTable",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
+1
View File
@@ -131,6 +131,7 @@ const PAGES = [
"audit",
"logs",
"notifications",
"files",
"downloads",
"plex",
] as const;
+22 -19
View File
@@ -10,6 +10,7 @@ import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { SavedViewsProvider } from "./components/SavedViewsProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { FilesProvider } from "./components/FilesProvider";
import { PlexProvider } from "./components/PlexProvider";
import { PlexViewBridge } from "./components/PlexViewBridge";
import { ChannelsProvider } from "./components/ChannelsProvider";
@@ -81,27 +82,29 @@ const root =
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<PlexViewBridge>
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
<FilesProvider>
<PlexProvider>
<PlexViewBridge>
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
source (feed ⟷ plex) by the active page and read the right live filters. */}
<SavedViewsProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
{/* The same quiet screen as the gate's, so a chunk that is still
<SavedViewsProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
{/* The same quiet screen as the gate's, so a chunk that is still
loading when the gate opens continues it instead of blanking. */}
<Suspense fallback={<BootScreen />}>
<App />
</Suspense>
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</SavedViewsProvider>
</PlexViewBridge>
</PlexProvider>
<Suspense fallback={<BootScreen />}>
<App />
</Suspense>
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</SavedViewsProvider>
</PlexViewBridge>
</PlexProvider>
</FilesProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>