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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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}}},
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"playlists": "Playlists",
|
||||
"plex": "Plex",
|
||||
"notifications": "Notifications",
|
||||
"files": "Files",
|
||||
"downloads": "Downloads",
|
||||
"stats": "Stats",
|
||||
"scheduler": "Scheduler",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"playlists": "Listák",
|
||||
"plex": "Plex",
|
||||
"notifications": "Értesítések",
|
||||
"files": "Fájlok",
|
||||
"downloads": "Letöltések",
|
||||
"stats": "Statisztika",
|
||||
"scheduler": "Ütemező",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -131,6 +131,7 @@ const PAGES = [
|
||||
"audit",
|
||||
"logs",
|
||||
"notifications",
|
||||
"files",
|
||||
"downloads",
|
||||
"plex",
|
||||
] as const;
|
||||
|
||||
+22
-19
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user