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
|
# 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`)
|
# 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
|
# 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.
|
# sending it at all, off the `upload_max_bytes` that `GET /api/files/usage` reports.
|
||||||
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
|
|
||||||
("POST", "/api/files/upload"): settings.download_upload_max_bytes,
|
("POST", "/api/files/upload"): settings.download_upload_max_bytes,
|
||||||
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_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/me/notifications/unread_count",
|
||||||
"/api/downloads",
|
"/api/downloads",
|
||||||
"/api/downloads/index",
|
"/api/downloads/index",
|
||||||
"/api/downloads/usage",
|
"/api/files",
|
||||||
|
"/api/files/folders",
|
||||||
|
"/api/files/usage",
|
||||||
"/api/sync/my-status",
|
"/api/sync/my-status",
|
||||||
"/api/admin/scheduler",
|
"/api/admin/scheduler",
|
||||||
"/api/admin/downloads",
|
"/api/admin/downloads",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def display_text(raw: str | None) -> str | None:
|
|||||||
blank -> None.
|
blank -> None.
|
||||||
|
|
||||||
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
|
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
|
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
|
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
|
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
|
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
|
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
|
import re
|
||||||
@@ -50,18 +51,13 @@ from app.routes._common import (
|
|||||||
)
|
)
|
||||||
from app.routes.files import (
|
from app.routes.files import (
|
||||||
MAX_LIST,
|
MAX_LIST,
|
||||||
SpooledUpload,
|
|
||||||
accessible_job,
|
accessible_job,
|
||||||
assets_for,
|
assets_for,
|
||||||
delete_file,
|
|
||||||
own_job_for_update,
|
own_job_for_update,
|
||||||
positions_for,
|
positions_for,
|
||||||
quota_detail,
|
quota_detail,
|
||||||
serialize,
|
serialize,
|
||||||
serialize_job,
|
serialize_job,
|
||||||
serve_file,
|
|
||||||
spool_upload,
|
|
||||||
ingest_spooled,
|
|
||||||
)
|
)
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
@@ -328,16 +324,6 @@ def enqueue_download(
|
|||||||
return serialize_job(db, job)
|
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):
|
class EnqueueEditIn(BaseModel):
|
||||||
source_job_id: int = 0
|
source_job_id: int = 0
|
||||||
edit_spec: dict
|
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).
|
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
||||||
_INDEX_RANK: dict[str, int] = {
|
_INDEX_RANK: dict[str, int] = {
|
||||||
JobStatus.DONE: 4,
|
JobStatus.DONE: 4,
|
||||||
@@ -477,90 +445,6 @@ def my_download_index(user: User = Depends(current_user), db: Session = Depends(
|
|||||||
return out
|
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")
|
@router.post("/{job_id}/pause")
|
||||||
def pause_download(
|
def pause_download(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
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)
|
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) --------------------------------------------------------
|
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -807,24 +671,6 @@ def unshare_download(
|
|||||||
return {"ok": True}
|
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) ------------------------------
|
# --- 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 datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import BinaryIO, Literal
|
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 import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -95,7 +95,6 @@ def serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
|||||||
"source_job_id": job.source_job_id,
|
"source_job_id": job.source_job_id,
|
||||||
"edit_spec": job.edit_spec,
|
"edit_spec": job.edit_spec,
|
||||||
"can_download": ready and job.status == JobStatus.DONE,
|
"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).
|
# The Files library: its folder, whether the user keeps it, and when it goes (NULL = never).
|
||||||
"folder_id": job.folder_id,
|
"folder_id": job.folder_id,
|
||||||
"kept": job.kept,
|
"kept": job.kept,
|
||||||
@@ -468,6 +467,42 @@ def delete_folder(
|
|||||||
return {"ok": True, "deleted": folder_id, "files_moved": moved}
|
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 ---------------------------------------------------------------------------------
|
# --- files ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -489,7 +524,8 @@ def list_files(
|
|||||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||||
.where(DownloadJob.status == JobStatus.DONE)
|
.where(DownloadJob.status == JobStatus.DONE)
|
||||||
)
|
)
|
||||||
if scope == "shared":
|
shared = scope == "shared"
|
||||||
|
if shared:
|
||||||
stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where(
|
stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where(
|
||||||
DownloadShare.to_user_id == user.id
|
DownloadShare.to_user_id == user.id
|
||||||
)
|
)
|
||||||
@@ -507,8 +543,10 @@ def list_files(
|
|||||||
func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like),
|
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 = {
|
key = {
|
||||||
"added": DownloadJob.created_at,
|
"added": DownloadShare.created_at if shared else DownloadJob.created_at,
|
||||||
"name": collated(name, lang_for_user(user)),
|
"name": collated(name, lang_for_user(user)),
|
||||||
"size": MediaAsset.size_bytes,
|
"size": MediaAsset.size_bytes,
|
||||||
}[sort]
|
}[sort]
|
||||||
@@ -517,20 +555,83 @@ def list_files(
|
|||||||
jobs = db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars().all()
|
jobs = db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars().all()
|
||||||
assets = assets_for(db, jobs)
|
assets = assets_for(db, jobs)
|
||||||
positions = positions_for(db, user, 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 [
|
return [
|
||||||
{
|
{
|
||||||
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
||||||
"position_seconds": positions.get(j.id, 0),
|
"position_seconds": positions.get(j.id, 0),
|
||||||
"shared": shared,
|
"shared": shared,
|
||||||
|
"shared_at": iso(shared_at.get(j.id)),
|
||||||
|
"source_key": sources[j.id],
|
||||||
}
|
}
|
||||||
for j in jobs
|
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):
|
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
|
name: str | None = None
|
||||||
|
uploader: str | None = None
|
||||||
|
uploader_url: str | None = None
|
||||||
|
extra_links: list | None = None
|
||||||
folder_id: int | None = None
|
folder_id: int | None = None
|
||||||
kept: bool | None = None
|
kept: bool | None = None
|
||||||
|
|
||||||
@@ -542,12 +643,21 @@ def update_file(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Rename a file (its display name; the file on disk keeps its own), move it to another folder
|
"""Rename a file and its channel, set its reference links (all display-only: the file on disk
|
||||||
(`folders.move`), or keep it until deleted / hand it back to its folder's retention."""
|
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")
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
sent = body.model_fields_set
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
job.display_name = display_text(body.name)
|
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 "folder_id" in sent:
|
||||||
if body.folder_id is None:
|
if body.folder_id is None:
|
||||||
raise refused(400, "file_folder_gone")
|
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
|
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
|
# 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.
|
# 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):
|
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.
|
`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")
|
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
|
assert spec.max == edge
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from sqlalchemy import select
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import service
|
from app.downloads import service
|
||||||
from app.models import DownloadJob, MediaAsset, User
|
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
|
from app.routes.files import delete_file, own_job_for_update
|
||||||
|
|
||||||
_REF = "https://example.com/video"
|
_REF = "https://example.com/video"
|
||||||
@@ -54,7 +54,6 @@ def _asset_held_by_two_jobs(db, status="error"):
|
|||||||
("status", "transition", "route", "left"),
|
("status", "transition", "route", "left"),
|
||||||
[
|
[
|
||||||
("error", service.cancel, cancel_download, "canceled"),
|
("error", service.cancel, cancel_download, "canceled"),
|
||||||
("error", service.delete, delete_download, None),
|
|
||||||
("error", service.delete, delete_file, None),
|
("error", service.delete, delete_file, None),
|
||||||
("queued", service.cancel, pause_download, "canceled"),
|
("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):
|
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)
|
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
|
assert r.status_code == 200, r.text
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(DownloadJob, job.id) is None
|
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)]
|
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 ------------------------------------------------------------------------------
|
# --- 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):
|
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()
|
alice, bob = make_user(), make_user()
|
||||||
job = _file(db, alice.id)
|
job = _file(db, alice.id)
|
||||||
|
|||||||
@@ -9,13 +9,26 @@ cannot reach, is ever read or written. DB-backed (real Postgres).
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
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
|
from app.routes._common import ProgressIn, kept_position
|
||||||
|
|
||||||
|
|
||||||
def _done_job(db, user) -> DownloadJob:
|
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(
|
job = DownloadJob(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
asset_id=asset.id,
|
||||||
source_kind="url",
|
source_kind="url",
|
||||||
source_ref="https://example.com/a.mp4",
|
source_ref="https://example.com/a.mp4",
|
||||||
status="done",
|
status="done",
|
||||||
@@ -59,7 +72,7 @@ def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
|
|||||||
"position_seconds": 754,
|
"position_seconds": 754,
|
||||||
}
|
}
|
||||||
|
|
||||||
[item] = c.get("/api/downloads").json()
|
[item] = c.get("/api/files").json()
|
||||||
assert item["position_seconds"] == 754
|
assert item["position_seconds"] == 754
|
||||||
# A second checkpoint moves it rather than adding a row.
|
# A second checkpoint moves it rather than adding a row.
|
||||||
assert _checkpoint(c, job.id, 900) == 900
|
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):
|
def test_an_item_never_played_lists_zero(db, make_user, as_user):
|
||||||
me = make_user()
|
me = make_user()
|
||||||
_done_job(db, me)
|
_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
|
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(owner), job.id, 100) == 100
|
||||||
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
|
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
|
||||||
|
|
||||||
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
|
assert as_user(owner).get("/api/files").json()[0]["position_seconds"] == 100
|
||||||
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
|
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):
|
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)
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args, str(src)], check=True)
|
||||||
|
|
||||||
owner = as_user(make_user())
|
owner = as_user(make_user())
|
||||||
r = owner.post(
|
r = owner.post("/api/files/upload", content=src.read_bytes(), headers=upload_headers(filename))
|
||||||
"/api/downloads/upload", content=src.read_bytes(), headers=upload_headers(filename)
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
job_id = r.json()["id"]
|
job_id = r.json()["id"]
|
||||||
assert r.json()["status"] == "queued" # every one of these fails the rule as it came
|
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"}}
|
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
||||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||||
owner = as_user(make_user())
|
owner = as_user(make_user())
|
||||||
job = owner.post(
|
job = owner.post("/api/files/upload", content=b"fake-video", headers=upload_headers("clip.mp4"))
|
||||||
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
|
|
||||||
)
|
|
||||||
assert job.status_code == 200, job.text
|
assert job.status_code == 200, job.text
|
||||||
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
||||||
assert link.status_code == 200, link.text
|
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)
|
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||||
owner = as_user(make_user())
|
owner = as_user(make_user())
|
||||||
job = owner.post(
|
job = owner.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"fake-video",
|
content=b"fake-video",
|
||||||
headers=upload_headers("clip.mp4", display_name="Holiday clip"),
|
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."""
|
multipart the old route would have spooled, not the raw body the new one expects."""
|
||||||
body, pulled = _counting_multipart()
|
body, pulled = _counting_multipart()
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=body,
|
content=body,
|
||||||
headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"},
|
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()
|
body, pulled = _counting_body()
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=body,
|
content=body,
|
||||||
headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"},
|
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))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"<script>alert(1)</script>",
|
content=b"<script>alert(1)</script>",
|
||||||
headers=upload_headers("report.html"),
|
headers=upload_headers("report.html"),
|
||||||
)
|
)
|
||||||
@@ -290,7 +290,7 @@ def test_upload_route_generic_forces_download(
|
|||||||
assert job["asset"]["media_kind"] == "file"
|
assert job["asset"]["media_kind"] == "file"
|
||||||
assert job["can_download"] is True
|
assert job["can_download"] is True
|
||||||
# The served file must be a neutral force-download attachment — never inline text/html.
|
# 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.status_code == 200
|
||||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
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…
|
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"<script>alert(1)</script>",
|
content=b"<script>alert(1)</script>",
|
||||||
headers=upload_headers("evil.html"),
|
headers=upload_headers("evil.html"),
|
||||||
)
|
)
|
||||||
@@ -324,7 +324,7 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Authenticated endpoint: never text/html.
|
# 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.status_code == 200
|
||||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
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))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"<script>alert(1)</script>",
|
content=b"<script>alert(1)</script>",
|
||||||
headers=upload_headers("notes.html"),
|
headers=upload_headers("notes.html"),
|
||||||
).json()
|
).json()
|
||||||
@@ -369,7 +369,7 @@ def test_public_watch_file_serves_media_inline_with_real_type(
|
|||||||
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"fake-mp4-bytes",
|
content=b"fake-mp4-bytes",
|
||||||
headers=upload_headers("clip.mp4"),
|
headers=upload_headers("clip.mp4"),
|
||||||
).json()
|
).json()
|
||||||
@@ -389,7 +389,7 @@ def test_upload_route_enforces_per_file_cap(
|
|||||||
db.commit()
|
db.commit()
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"way over the cap",
|
content=b"way over the cap",
|
||||||
headers=upload_headers("big.bin"),
|
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()
|
db.commit()
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
|
|
||||||
at_the_cap = c.post(
|
at_the_cap = c.post("/api/files/upload", content=payload, headers=upload_headers("exact.bin"))
|
||||||
"/api/downloads/upload", content=payload, headers=upload_headers("exact.bin")
|
|
||||||
)
|
|
||||||
assert at_the_cap.status_code == 200, at_the_cap.text
|
assert at_the_cap.status_code == 200, at_the_cap.text
|
||||||
|
|
||||||
over = c.post(
|
over = c.post("/api/files/upload", content=payload + b"x", headers=upload_headers("over.bin"))
|
||||||
"/api/downloads/upload", content=payload + b"x", headers=upload_headers("over.bin")
|
|
||||||
)
|
|
||||||
assert over.status_code == 422
|
assert over.status_code == 422
|
||||||
assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)}
|
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))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post("/api/files/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin"))
|
||||||
"/api/downloads/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
|
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
|
||||||
assert asset is not None
|
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())
|
c = as_user(make_user())
|
||||||
|
|
||||||
# 1. the upload route — the name arrives in a request header.
|
# 1. the upload route — the name arrives in a request header.
|
||||||
up = c.post(
|
up = c.post("/api/files/upload", content=b"video", headers=upload_headers("a.mp4", long_name))
|
||||||
"/api/downloads/upload", content=b"video", headers=upload_headers("a.mp4", long_name)
|
|
||||||
)
|
|
||||||
assert up.status_code == 200, up.text
|
assert up.status_code == 200, up.text
|
||||||
assert up.json()["display_name"] == bound
|
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.status_code == 200, edit.text
|
||||||
assert edit.json()["display_name"] == bound
|
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(
|
patched = c.patch(
|
||||||
f"/api/downloads/{up.json()['id']}",
|
f"/api/files/{up.json()['id']}",
|
||||||
json={"display_name": long_name, "display_uploader": long_name},
|
json={"name": long_name, "uploader": long_name},
|
||||||
)
|
)
|
||||||
assert patched.status_code == 200, patched.text
|
assert patched.status_code == 200, patched.text
|
||||||
assert patched.json()["display_name"] == bound
|
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_download"): {"display_text(body.display_name)"},
|
||||||
("routes/downloads.py", "enqueue_edit"): {"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", "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", "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"): {
|
("routes/files.py", "spool_upload"): {
|
||||||
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
|
"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())
|
c = as_user(make_user())
|
||||||
|
|
||||||
ok = c.post(
|
ok = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"data",
|
content=b"data",
|
||||||
headers=upload_headers("clip.bin", "Nyári vakáció"),
|
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
|
long_name = "n" * 300
|
||||||
bounded = c.post(
|
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.status_code == 200, bounded.text
|
||||||
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
|
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, "release_read_connection", spy_release)
|
||||||
monkeypatch.setattr(files_routes, "capped_stream_to", spy_stream)
|
monkeypatch.setattr(files_routes, "capped_stream_to", spy_stream)
|
||||||
c = as_user(make_user())
|
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 r.status_code == 200, r.text
|
||||||
assert seen["before"] is True, (
|
assert seen["before"] is True, (
|
||||||
"nothing had opened a transaction before the release, so this test proves nothing — the "
|
"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
|
from app import sysconfig
|
||||||
|
|
||||||
c = as_user(make_user())
|
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.status_code == 200, r.text
|
||||||
assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
|
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.
|
# And it is the KNOB, not a constant: a lowered override must show through.
|
||||||
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
|
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
|
||||||
db.commit()
|
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(
|
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"
|
staging = Path(settings.files_root) / ".staging" / "uploads"
|
||||||
c = as_user(make_user())
|
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 r.status_code == 422
|
||||||
assert staging.exists(), "the dependency did not get as far as creating the staging dir"
|
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"
|
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):
|
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||||
c = as_user(make_user())
|
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.status_code == 400
|
||||||
assert r.json()["detail"]["code"] == "upload_empty"
|
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):
|
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
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")
|
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||||
assert r.status_code == 409
|
assert r.status_code == 409
|
||||||
|
|
||||||
@@ -805,7 +796,7 @@ def test_edit_rejects_a_generic_upload_source(
|
|||||||
):
|
):
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
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(
|
r = c.post(
|
||||||
"/api/downloads/edit",
|
"/api/downloads/edit",
|
||||||
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
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")
|
_probe(monkeypatch, vcodec="h264")
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
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()
|
).json()
|
||||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).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
|
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()
|
owner = make_user()
|
||||||
c = as_user(owner)
|
c = as_user(owner)
|
||||||
job = c.post(
|
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()
|
).json()
|
||||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||||
db.get(MediaAsset, job["asset"]["id"]).status = "pending"
|
db.get(MediaAsset, job["asset"]["id"]).status = "pending"
|
||||||
|
|||||||
+18
-7
@@ -87,6 +87,7 @@ export default function App() {
|
|||||||
feed: seedLayout("feed", prefs),
|
feed: seedLayout("feed", prefs),
|
||||||
plex: seedLayout("plex", prefs),
|
plex: seedLayout("plex", prefs),
|
||||||
playlists: seedLayout("playlists", prefs),
|
playlists: seedLayout("playlists", prefs),
|
||||||
|
files: seedLayout("files", prefs),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
@@ -119,6 +120,17 @@ export default function App() {
|
|||||||
localKey: LS.playlistsCollapsed,
|
localKey: LS.playlistsCollapsed,
|
||||||
parse: Boolean,
|
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
|
// (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.)
|
// 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.
|
{/* 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
|
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 && (
|
{railDef && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<railDef.Rail
|
<railDef.Rail
|
||||||
layout={panelLayouts[railDef.panelId]}
|
layout={panelLayouts[railDef.panelId]}
|
||||||
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
||||||
collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
|
collapsed={railCollapse[railDef.collapse][0]}
|
||||||
onToggleCollapse={() =>
|
onToggleCollapse={() => {
|
||||||
railDef.collapse === "playlists"
|
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
|
||||||
? setPlaylistsCollapsed(!playlistsCollapsed)
|
setCollapsed(!collapsed);
|
||||||
: setFilterCollapsed(!filterCollapsed)
|
}}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared
|
// 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,
|
// `confirmThen` once labelled it with the dialog's question — the cancel dialog's own "Cancel" beside
|
||||||
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
|
// "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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -73,7 +74,6 @@ const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
|
|||||||
display_uploader_url: null,
|
display_uploader_url: null,
|
||||||
extra_links: [],
|
extra_links: [],
|
||||||
can_download: status === "done",
|
can_download: status === "done",
|
||||||
expired: false,
|
|
||||||
folder_id: null,
|
folder_id: null,
|
||||||
kept: false,
|
kept: false,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
@@ -105,7 +105,7 @@ async function pick(row: string, label: string) {
|
|||||||
|
|
||||||
describe("DownloadCenter confirm dialogs", () => {
|
describe("DownloadCenter confirm dialogs", () => {
|
||||||
it("label the confirm button with the action, not the dialog's question", async () => {
|
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(
|
render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<DownloadCenter />
|
<DownloadCenter />
|
||||||
@@ -115,18 +115,9 @@ describe("DownloadCenter confirm dialogs", () => {
|
|||||||
// The destructive and rare row buttons live in the row's "…".
|
// The destructive and rare row buttons live in the row's "…".
|
||||||
await pick("Clip 1", "downloads.actions.cancel");
|
await pick("Clip 1", "downloads.actions.cancel");
|
||||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
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([
|
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
|
||||||
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
|
["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",
|
"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
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The Download Center: a download row keeps its primary action (play; pause / resume /
|
// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and
|
||||||
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
|
// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a
|
||||||
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
|
// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx).
|
||||||
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
|
// i18n is key-passthrough, so labels read as keys.
|
||||||
// menu. i18n is key-passthrough, so labels read as keys.
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -68,7 +67,6 @@ const job = (
|
|||||||
display_uploader_url: null,
|
display_uploader_url: null,
|
||||||
extra_links: [],
|
extra_links: [],
|
||||||
can_download: status === "done",
|
can_download: status === "done",
|
||||||
expired: false,
|
|
||||||
job_kind: "download",
|
job_kind: "download",
|
||||||
asset: { media_kind: "video", duration_s: 60 },
|
asset: { media_kind: "video", duration_s: 60 },
|
||||||
...extra,
|
...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", () => {
|
describe("menuGroups", () => {
|
||||||
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
|
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
|
||||||
it("rules between non-empty groups only", () => {
|
it("rules between non-empty groups only", () => {
|
||||||
|
|||||||
@@ -1,47 +1,29 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { ClipboardPaste, Pause, Play, Plus, RefreshCw, Settings2, UploadCloud } from "lucide-react";
|
||||||
import {
|
|
||||||
ClipboardPaste,
|
|
||||||
Download,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
PlayCircle,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Settings2,
|
|
||||||
Share2,
|
|
||||||
UploadCloud,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
import { useTabPanel } from "./ui/tabPanel";
|
import { useTabPanel } from "./ui/tabPanel";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used.
|
||||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
const UploadDialog = lazy(() => import("./UploadDialog"));
|
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
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 { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
import { DownloadRow, IconBtn } from "./downloadRow";
|
||||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
import { ErrorModal } from "./downloadModals";
|
||||||
import { AdminSystem } from "./downloadAdmin";
|
import { AdminSystem } from "./downloadAdmin";
|
||||||
import NavGrid from "./ui/NavGrid";
|
import NavGrid from "./ui/NavGrid";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { IconLink } from "./ui/LinkButton";
|
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { MoreButton } from "./ui/MoreButton";
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
|
import { useDownloadActions } from "./useDownloadActions";
|
||||||
|
|
||||||
const jobKey = (job: DownloadJob) => job.id;
|
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() {
|
export default function DownloadCenter() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -66,12 +51,6 @@ export default function DownloadCenter() {
|
|||||||
const [addSource, setAddSource] = useState<string | null>(null);
|
const [addSource, setAddSource] = useState<string | null>(null);
|
||||||
const [upload, setUpload] = useState(false);
|
const [upload, setUpload] = useState(false);
|
||||||
const [manage, setManage] = 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);
|
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
|
// 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) =>
|
intervalMs: (data) =>
|
||||||
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
|
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 jobs = jobsQ.data ?? [];
|
||||||
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
||||||
const library = jobs.filter((j) => j.status === "done");
|
|
||||||
|
|
||||||
// --- The rows' ONE menu -------------------------------------------------------------
|
// --- 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
|
// 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 rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
|
||||||
const actions = useDownloadActions({
|
const actions = useDownloadActions();
|
||||||
onPlay: setPlayJob,
|
const menuItems = rowMenu.target && tab === "queue" ? actions.items(rowMenu.target) : [];
|
||||||
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) : [];
|
|
||||||
// The row's menu triggers and its "…".
|
// The row's menu triggers and its "…".
|
||||||
const rowMenuProps = (job: DownloadJob) => {
|
const rowMenuProps = (job: DownloadJob) => {
|
||||||
const mine = rowMenu.open && rowMenu.target?.id === job.id;
|
const mine = rowMenu.open && rowMenu.target?.id === job.id;
|
||||||
@@ -133,8 +98,6 @@ export default function DownloadCenter() {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
|
{ 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") }] : []),
|
...(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)
|
// 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.
|
// 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");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
|
{/* 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 />}
|
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||||
|
|
||||||
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
|
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
|
||||||
@@ -361,16 +241,6 @@ export default function DownloadCenter() {
|
|||||||
)}
|
)}
|
||||||
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||||
{manage && <ProfileEditor onClose={() => setManage(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 && (
|
{errorJob && (
|
||||||
<ErrorModal
|
<ErrorModal
|
||||||
job={errorJob}
|
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 { YouTubeIcon } from "../lib/brandIcons";
|
||||||
import type { Me } from "../lib/api";
|
import type { Me } from "../lib/api";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
|
import { useFiles, useFilesActions } from "./filesContext";
|
||||||
import PlexSearchBox from "./PlexSearchBox";
|
import PlexSearchBox from "./PlexSearchBox";
|
||||||
import PlexScopeToggle from "./PlexScopeToggle";
|
import PlexScopeToggle from "./PlexScopeToggle";
|
||||||
import PlexViewToggle from "./PlexViewToggle";
|
import PlexViewToggle from "./PlexViewToggle";
|
||||||
@@ -37,6 +38,8 @@ const Header = forwardRef<
|
|||||||
const { search: playlistsSearch } = usePlaylists();
|
const { search: playlistsSearch } = usePlaylists();
|
||||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
||||||
const { search: channelsSearch } = useChannels();
|
const { search: channelsSearch } = useChannels();
|
||||||
|
const { search: filesSearch } = useFiles();
|
||||||
|
const { setSearch: setFilesSearch } = useFilesActions();
|
||||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
||||||
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
// 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?.();
|
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
@@ -77,6 +80,15 @@ const Header = forwardRef<
|
|||||||
onGo={blur}
|
onGo={blur}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (page === "files") {
|
||||||
|
search = (
|
||||||
|
<SearchBar
|
||||||
|
value={filesSearch}
|
||||||
|
placeholder={t("files.searchPlaceholder")}
|
||||||
|
onChange={setFilesSearch}
|
||||||
|
onGo={blur}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (page === "playlists") {
|
} else if (page === "playlists") {
|
||||||
search = (
|
search = (
|
||||||
<SearchBar
|
<SearchBar
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const baseJob: DownloadJob = {
|
|||||||
display_uploader_url: null,
|
display_uploader_url: null,
|
||||||
extra_links: [],
|
extra_links: [],
|
||||||
can_download: true,
|
can_download: true,
|
||||||
expired: false,
|
|
||||||
folder_id: null,
|
folder_id: null,
|
||||||
kept: false,
|
kept: false,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
@@ -112,7 +111,7 @@ describe("LibraryPlayer", () => {
|
|||||||
);
|
);
|
||||||
const video = document.body.querySelector("video");
|
const video = document.body.querySelector("video");
|
||||||
expect(video).not.toBeNull();
|
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.
|
// No <audio> element for a video asset.
|
||||||
expect(document.body.querySelector("audio")).toBeNull();
|
expect(document.body.querySelector("audio")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -131,7 +130,7 @@ describe("LibraryPlayer", () => {
|
|||||||
);
|
);
|
||||||
const audio = document.body.querySelector("audio");
|
const audio = document.body.querySelector("audio");
|
||||||
expect(audio).not.toBeNull();
|
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();
|
expect(document.body.querySelector("video")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +178,7 @@ describe("LibraryPlayer", () => {
|
|||||||
expect(loaded().currentTime).toBe(0);
|
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({
|
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
job_id: 7,
|
job_id: 7,
|
||||||
@@ -188,7 +187,9 @@ describe("LibraryPlayer", () => {
|
|||||||
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
|
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
const job = withAsset({ width: 1280, height: 720 });
|
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 { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||||
const video = loaded();
|
const video = loaded();
|
||||||
video.dispatchEvent(new Event("playing"));
|
video.dispatchEvent(new Event("playing"));
|
||||||
@@ -196,8 +197,9 @@ describe("LibraryPlayer", () => {
|
|||||||
video.dispatchEvent(new Event("pause"));
|
video.dispatchEvent(new Event("pause"));
|
||||||
|
|
||||||
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
|
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(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.
|
// Closing the player sends the point the way that survives the page going away.
|
||||||
unmount();
|
unmount();
|
||||||
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
|
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
|
||||||
@@ -213,10 +215,10 @@ describe("LibraryPlayer", () => {
|
|||||||
});
|
});
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
const job = withAsset({ width: 1280, height: 720 });
|
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 = () => {};
|
let answer: (jobs: DownloadJob[]) => void = () => {};
|
||||||
const poll = client.fetchQuery({
|
const poll = client.fetchQuery({
|
||||||
queryKey: qk.downloads(),
|
queryKey: qk.files({}),
|
||||||
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
|
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
|
||||||
});
|
});
|
||||||
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||||
@@ -226,10 +228,10 @@ describe("LibraryPlayer", () => {
|
|||||||
unmount();
|
unmount();
|
||||||
answer([{ ...job, position_seconds: 10 }]);
|
answer([{ ...job, position_seconds: 10 }]);
|
||||||
await poll;
|
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.
|
// Once the point has landed, the listing asks the server again.
|
||||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||||
settled?.();
|
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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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 —
|
// 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
|
// 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).
|
// 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,
|
artwork: poster ?? null,
|
||||||
});
|
});
|
||||||
useMediaKeys(media);
|
useMediaKeys(media);
|
||||||
// The library row's bar follows the point at once — both listings this job may sit in — rather
|
// The library row's bar follows the point at once — every library listing this file may sit in
|
||||||
// than after the next refetch. On the way out a listing poll may already be out, having read the
|
// (each folder, search and the shared view is its own key under `files`) — rather than after the
|
||||||
// server before the closing point lands: it is cancelled, or its answer replaced the row's point
|
// next refetch. On the way out a listing read may already be out, having read the server before
|
||||||
// with the older one, Continue opened there, and its first checkpoint wrote it back over the right
|
// the closing point lands: it is cancelled, or its answer replaced the row's point with the older
|
||||||
// one. Once the point is on the server, the listings are read again.
|
// one, Continue opened there, and its first checkpoint wrote it back over the right one. Once the
|
||||||
const lists = [qk.downloads(), qk.downloadsShared()];
|
// point is on the server, the listings are read again.
|
||||||
|
const lists = { queryKey: qk.files() };
|
||||||
const keepPoint = (position: number, duration: number, leaving: boolean) => {
|
const keepPoint = (position: number, duration: number, leaving: boolean) => {
|
||||||
const at = keptPosition(position, duration);
|
const at = keptPosition(position, duration);
|
||||||
for (const key of lists) {
|
if (leaving) void qc.cancelQueries(lists);
|
||||||
if (leaving) void qc.cancelQueries({ queryKey: key, exact: true });
|
qc.setQueriesData<DownloadJob[]>(lists, (jobs) =>
|
||||||
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
|
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
|
||||||
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (leaving) {
|
if (leaving) {
|
||||||
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
|
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(() => {});
|
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The local-upload dialog. It's a general file upload (any type, no format list): a picked
|
// 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
|
// 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
|
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
||||||
// key-passthrough.
|
// key-passthrough.
|
||||||
@@ -10,11 +10,12 @@ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/re
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
const h = vi.hoisted(() => ({
|
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
|
// 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.
|
// 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(),
|
notify: vi.fn(),
|
||||||
|
navigateTo: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -25,7 +26,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../lib/api", () => ({
|
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() }));
|
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.
|
// 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")>()),
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||||
notify: h.notify,
|
notify: h.notify,
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
|
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
|
||||||
|
|
||||||
import UploadDialog from "./UploadDialog";
|
import UploadDialog from "./UploadDialog";
|
||||||
|
|
||||||
const wrap = (onClose = vi.fn()) => {
|
const wrap = (onClose = vi.fn(), folderId: number | null = null) => {
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<UploadDialog onClose={onClose} />
|
<UploadDialog folderId={folderId} onClose={onClose} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -50,10 +51,10 @@ const wrap = (onClose = vi.fn()) => {
|
|||||||
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
h.uploadDownload.mockClear();
|
h.uploadFile.mockClear();
|
||||||
h.notify.mockClear();
|
h.notify.mockClear();
|
||||||
h.downloadUsage.mockClear();
|
h.filesUsage.mockClear();
|
||||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||||
});
|
});
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -73,8 +74,8 @@ describe("UploadDialog", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||||
|
|
||||||
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||||
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
|
const [passedFile, opts] = h.uploadFile.mock.calls[0]!;
|
||||||
expect(passedFile).toBe(f);
|
expect(passedFile).toBe(f);
|
||||||
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
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 () => {
|
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`:
|
// 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.
|
// "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();
|
wrap();
|
||||||
fireEvent.change(fileInput(), {
|
fireEvent.change(fileInput(), {
|
||||||
target: { files: [new File(["x"], "clip.mov", { type: "video/quicktime" })] },
|
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", () => {
|
it("keeps Upload disabled until a file is chosen", () => {
|
||||||
wrap();
|
wrap();
|
||||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
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
|
// 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
|
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
|
||||||
// moves.
|
// moves.
|
||||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||||
wrap();
|
wrap();
|
||||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||||
pickFile(CAP + 1, "big.bin");
|
pickFile(CAP + 1, "big.bin");
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
|
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
|
// 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
|
// 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.
|
// does not exist. The drop would have worked.
|
||||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||||
wrap();
|
wrap();
|
||||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||||
pickFile(CAP + 1, "big.bin");
|
pickFile(CAP + 1, "big.bin");
|
||||||
const zone = screen.getByText("big.bin").closest("button")!;
|
const zone = screen.getByText("big.bin").closest("button")!;
|
||||||
expect(zone.className).toContain("border-danger");
|
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
|
// `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
|
// 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.
|
// 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();
|
wrap();
|
||||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||||
pickFile(CAP, "fits.bin");
|
pickFile(CAP, "fits.bin");
|
||||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||||
btn.focus();
|
btn.focus();
|
||||||
@@ -170,17 +192,17 @@ describe("UploadDialog", () => {
|
|||||||
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
||||||
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
|
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
|
||||||
fireEvent.click(btn);
|
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 () => {
|
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();
|
wrap();
|
||||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||||
pickFile(CAP, "exact.bin");
|
pickFile(CAP, "exact.bin");
|
||||||
expect(screen.queryByRole("alert")).toBeNull();
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
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 () => {
|
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
|
// 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
|
// 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.
|
// 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();
|
wrap();
|
||||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||||
pickFile(CAP + 1, "a.bin");
|
pickFile(CAP + 1, "a.bin");
|
||||||
const first = await screen.findByRole("alert");
|
const first = await screen.findByRole("alert");
|
||||||
pickFile(CAP + 2, "b.bin");
|
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
|
// 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
|
// (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
|
// 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,
|
// 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.
|
// say which, and point the user there.
|
||||||
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
|
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
|
||||||
// skip the whole dialog.
|
// skip the whole dialog.
|
||||||
const uploadedKey = (status: string) =>
|
const uploadedKey = (status: string) =>
|
||||||
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
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 { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const qc = useQueryClient();
|
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
|
// 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.
|
// 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.
|
// 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;
|
?.upload_max_bytes;
|
||||||
const tooBig = !!file && !!cap && file.size > cap;
|
const tooBig = !!file && !!cap && file.size > cap;
|
||||||
const refusalId = useId();
|
const refusalId = useId();
|
||||||
@@ -80,8 +89,9 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
setPct(0);
|
setPct(0);
|
||||||
const displayName = name.trim() || undefined;
|
const displayName = name.trim() || undefined;
|
||||||
try {
|
try {
|
||||||
const job = await api.uploadDownload(file, {
|
const job = await api.uploadFile(file, {
|
||||||
displayName,
|
displayName,
|
||||||
|
folderId,
|
||||||
onProgress: setPct,
|
onProgress: setPct,
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
});
|
});
|
||||||
@@ -89,7 +99,10 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: t(uploadedKey(job.status)),
|
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();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const [accurate, setAccurate] = useState(true);
|
const [accurate, setAccurate] = useState(true);
|
||||||
const [name, setName] = useState("");
|
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 tc = (sec: number) => formatTimecode(sec, duration >= 3600);
|
||||||
const aspect =
|
const aspect =
|
||||||
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
(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.
|
// so another try adds only the rest.
|
||||||
if (done.length) {
|
if (done.length) {
|
||||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
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)));
|
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
|
// 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
|
// 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 }) => {
|
onSuccess: ({ files }) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
qc.invalidateQueries({ queryKey: qk.storageUsage() });
|
||||||
notify({ level: "success", message: t("editor.created", { count: files }) });
|
notify({ level: "success", message: t("editor.created", { count: files }) });
|
||||||
onClose();
|
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
|
// 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,
|
// 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.
|
// which is a coincidence, not a guarantee.
|
||||||
vi.spyOn(api, "downloadUsage").mockResolvedValue({
|
vi.spyOn(api, "filesUsage").mockResolvedValue({
|
||||||
upload_max_bytes: 1_000_000,
|
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 () => {
|
it("names an upload the server refused", async () => {
|
||||||
// A 502 from the proxy raises only the self-clearing connection notice; the next successful poll
|
// 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.
|
// 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();
|
mount();
|
||||||
pick();
|
pick();
|
||||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
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 () => {
|
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,
|
// 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.
|
// 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();
|
mount();
|
||||||
pick();
|
pick();
|
||||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
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
|
/** 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 (
|
return (
|
||||||
text: string | null | undefined,
|
text: string | null | undefined,
|
||||||
link: string | null | undefined,
|
link: string | null | undefined,
|
||||||
named: Named = "title",
|
named: Named = "title",
|
||||||
|
linkLabel?: string,
|
||||||
): ActionMenuItem[] => [
|
): ActionMenuItem[] => [
|
||||||
...(text
|
...(text
|
||||||
? [
|
? [
|
||||||
@@ -47,7 +50,7 @@ export function useCopyItems() {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "copy-link",
|
id: "copy-link",
|
||||||
label: t("common.copy.link"),
|
label: linkLabel ?? t("common.copy.link"),
|
||||||
icon: <Link2 className="w-4 h-4" />,
|
icon: <Link2 className="w-4 h-4" />,
|
||||||
inGesture: true,
|
inGesture: true,
|
||||||
onSelect: () => copy(link, t("common.copy.linkDone")),
|
onSelect: () => copy(link, t("common.copy.linkDone")),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
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
|
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
||||||
// editing and the failure-detail window.
|
// editing and the failure-detail window.
|
||||||
|
|
||||||
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
// Edit a library file's details: its channel (name + link) and extra reference links. Display-only
|
||||||
// All are display-only overrides — the on-disk file is never renamed.
|
// 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 }) {
|
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
|
||||||
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
||||||
const [channelUrl, setChannelUrl] = useState(
|
const [channelUrl, setChannelUrl] = useState(
|
||||||
job.display_uploader_url || job.asset?.uploader_url || "",
|
job.display_uploader_url || job.asset?.uploader_url || "",
|
||||||
@@ -32,14 +31,13 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
|||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api.updateDownloadMeta(job.id, {
|
api.updateFile(job.id, {
|
||||||
display_name: name.trim(),
|
uploader: channel.trim(),
|
||||||
display_uploader: channel.trim(),
|
uploader_url: channelUrl.trim(),
|
||||||
display_uploader_url: channelUrl.trim(),
|
|
||||||
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
invalidateDownloads(qc);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
meta: { failTitle: "errors.writeFailed.downloadDetails" },
|
meta: { failTitle: "errors.writeFailed.downloadDetails" },
|
||||||
@@ -48,10 +46,6 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
|||||||
return (
|
return (
|
||||||
<Modal initialFocus="field" title={t("downloads.edit.title")} onClose={onClose}>
|
<Modal initialFocus="field" title={t("downloads.edit.title")} onClose={onClose}>
|
||||||
<div className="space-y-3">
|
<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">
|
<label className="block">
|
||||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
<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)} />
|
<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"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => save.mutate()}
|
onClick={() => save.mutate()}
|
||||||
disabled={!name.trim() || save.isPending}
|
disabled={save.isPending}
|
||||||
>
|
>
|
||||||
{t("downloads.edit.save")}
|
{t("downloads.edit.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const baseJob: DownloadJob = {
|
|||||||
display_uploader_url: null,
|
display_uploader_url: null,
|
||||||
extra_links: [],
|
extra_links: [],
|
||||||
can_download: true,
|
can_download: true,
|
||||||
expired: false,
|
|
||||||
folder_id: null,
|
folder_id: null,
|
||||||
kept: false,
|
kept: false,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useState, type ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react";
|
import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { qk } from "../lib/queryKeys";
|
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -21,9 +19,9 @@ import { MediaProgress } from "./ui/MediaCard";
|
|||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
// Download-row presentation + the small shared primitives (status pill, icon button, the job's
|
||||||
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
// picture), split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the
|
||||||
// library, shared, and admin tabs.
|
// queue and the admin tab; the Files list borrows the picture.
|
||||||
|
|
||||||
const STATUS_CLS: Record<string, string> = {
|
const STATUS_CLS: Record<string, string> = {
|
||||||
queued: "text-muted",
|
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>
|
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const key = job.expired ? "expired" : job.status;
|
|
||||||
return (
|
return (
|
||||||
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||||
{t(`downloads.status.${key}`)}
|
{t(`downloads.status.${job.status}`)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,7 +73,9 @@ export function StatusPill({ job }: { job: DownloadJob }) {
|
|||||||
const THUMB_BOX =
|
const THUMB_BOX =
|
||||||
"w-28 @max-[40rem]/dlrow:w-24 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
|
"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
|
// 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
|
// 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
|
// 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.
|
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
|
||||||
if (job.asset?.media_kind === "file") {
|
if (job.asset?.media_kind === "file") {
|
||||||
return (
|
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" />
|
<File className="w-8 h-8" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -116,7 +115,7 @@ function Thumb({ job }: { job: DownloadJob }) {
|
|||||||
const duration = job.asset?.duration_s ?? 0;
|
const duration = job.asset?.duration_s ?? 0;
|
||||||
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
|
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div className={`${THUMB_BOX} relative`}>
|
<div className={`${box} relative`}>
|
||||||
<MediaProgress pct={pct} />
|
<MediaProgress pct={pct} />
|
||||||
{src ? (
|
{src ? (
|
||||||
<img
|
<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']`}
|
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]">
|
<div className="[grid-area:thumb]">
|
||||||
<Thumb job={job} />
|
<JobThumb job={job} />
|
||||||
</div>
|
</div>
|
||||||
<div className="[grid-area:head] min-w-0">
|
<div className="[grid-area:head] min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -379,42 +378,3 @@ export function DownloadRow({
|
|||||||
</div>
|
</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 PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
|
||||||
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
|
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
|
||||||
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
||||||
|
const Files = lazyWithPreload(() => import("./Files"));
|
||||||
|
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
|
||||||
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
|
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
|
||||||
const Stats = lazyWithPreload(() => import("./Stats"));
|
const Stats = lazyWithPreload(() => import("./Stats"));
|
||||||
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
||||||
@@ -54,6 +56,7 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
|
|||||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
playlists: { Component: Playlists, maxWidth: "64rem" },
|
||||||
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
||||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||||
|
files: { Component: Files, maxWidth: "80rem" },
|
||||||
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
||||||
stats: { Component: Stats },
|
stats: { Component: Stats },
|
||||||
scheduler: { Component: Scheduler },
|
scheduler: { Component: Scheduler },
|
||||||
@@ -73,8 +76,9 @@ type RailProps = {
|
|||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own).
|
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
|
||||||
type RailCollapseKey = "filter" | "playlists";
|
// have their own).
|
||||||
|
type RailCollapseKey = "filter" | "playlists" | "files";
|
||||||
|
|
||||||
export type RailDef = {
|
export type RailDef = {
|
||||||
Rail: ComponentType<RailProps>;
|
Rail: ComponentType<RailProps>;
|
||||||
@@ -83,11 +87,12 @@ export type RailDef = {
|
|||||||
collapse: RailCollapseKey;
|
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>> = {
|
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
|
||||||
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
|
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
|
||||||
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
||||||
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
|
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
|
/** 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.
|
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
|
||||||
if (params.has("v")) return "plex";
|
if (params.has("v")) return "plex";
|
||||||
if (params.has("pl")) return "playlists";
|
if (params.has("pl")) return "playlists";
|
||||||
|
if (params.has("folder")) return "files";
|
||||||
if (hasFilterParams(params) || params.has("chan")) return "feed";
|
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
|
// 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
|
// 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]. */
|
/** file → [how many plain Pressables, why they are not titles]. */
|
||||||
const PLAIN: Record<string, [number, string]> = {
|
const PLAIN: Record<string, [number, string]> = {
|
||||||
|
"FilesRail.tsx": [1, "the load-error retry"],
|
||||||
"Home.tsx": [
|
"Home.tsx": [
|
||||||
2,
|
2,
|
||||||
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
"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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
|
||||||
Ban,
|
|
||||||
Download,
|
|
||||||
Link2,
|
|
||||||
Pause,
|
|
||||||
Pencil,
|
|
||||||
Play,
|
|
||||||
PlayCircle,
|
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
|
||||||
Scissors,
|
|
||||||
Share2,
|
|
||||||
SkipBack,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
|
||||||
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
|
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { useCopyItems } from "./copyItems";
|
import { useCopyItems } from "./copyItems";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
import { menuGroups } from "./ui/menuGroups";
|
import { menuGroups } from "./ui/menuGroups";
|
||||||
|
|
||||||
// A download's ONE action list: the row's "…", its right-click, long-press and Shift+F10,
|
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
|
||||||
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
|
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
|
||||||
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
|
// sight; the copies and Cancel live here. A finished download is a library file: its actions are
|
||||||
// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
|
// the Files page's (useFileActions).
|
||||||
// managing the shares, re-download, the copies and the destructive rows — lives here.
|
|
||||||
|
|
||||||
export type DownloadTab = "queue" | "done" | "shared";
|
export type JobOp = "pause" | "resume" | "cancel";
|
||||||
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
|
|
||||||
|
|
||||||
// The Cancel confirmation of a queued job; an upload's names what it deletes.
|
// The Cancel confirmation of a queued job; an upload's names what it deletes.
|
||||||
const CANCEL_DOWNLOAD = {
|
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
|
// 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.
|
// 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 }> = {
|
export function useDownloadActions() {
|
||||||
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;
|
|
||||||
}) {
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -95,83 +56,16 @@ export function useDownloadActions({
|
|||||||
? api.pauseDownload(id)
|
? api.pauseDownload(id)
|
||||||
: op === "resume"
|
: op === "resume"
|
||||||
? api.resumeDownload(id)
|
? api.resumeDownload(id)
|
||||||
: op === "cancel"
|
: api.cancelDownload(id),
|
||||||
? api.cancelDownload(id)
|
|
||||||
: op === "redownload"
|
|
||||||
? api.redownloadDownload(id)
|
|
||||||
: api.deleteDownload(id),
|
|
||||||
onSuccess: () => invalidateDownloads(qc),
|
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}`)),
|
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) => {
|
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
||||||
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
|
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" />;
|
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);
|
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
|
||||||
|
|
||||||
function queueItems(job: DownloadJob): ActionMenuItem[] {
|
function queueItems(job: DownloadJob): ActionMenuItem[] {
|
||||||
@@ -212,112 +106,11 @@ export function useDownloadActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function libraryItems(job: DownloadJob): ActionMenuItem[] {
|
/** The job's menu rows. */
|
||||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
const items = (job: DownloadJob): ActionMenuItem[] => queueItems(job);
|
||||||
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 queue's visible primary action and the error dialog's Retry. */
|
/** The queue's visible primary action and the error dialog's Retry. */
|
||||||
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
|
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. */
|
return { items, run };
|
||||||
const share = (job: DownloadJob) => void quickShare(job);
|
|
||||||
|
|
||||||
return { items, run, share };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"tabs": {
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"done": "Library",
|
|
||||||
"shared": "Shared with me",
|
|
||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -51,8 +49,7 @@
|
|||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"done": "Ready",
|
"done": "Ready",
|
||||||
"error": "Failed",
|
"error": "Failed",
|
||||||
"canceled": "Canceled",
|
"canceled": "Canceled"
|
||||||
"expired": "Expired"
|
|
||||||
},
|
},
|
||||||
"phase": {
|
"phase": {
|
||||||
"video": "Downloading video",
|
"video": "Downloading video",
|
||||||
@@ -75,9 +72,6 @@
|
|||||||
"download": "Save to my device",
|
"download": "Save to my device",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"editDetails": "Edit details…",
|
|
||||||
"share": "Share (copy link)",
|
|
||||||
"shareManage": "Manage sharing…",
|
|
||||||
"edit": "Edit / trim…",
|
"edit": "Edit / trim…",
|
||||||
"redownload": "Re-download (rebuild file)",
|
"redownload": "Re-download (rebuild file)",
|
||||||
"removeShared": "Remove from my list"
|
"removeShared": "Remove from my list"
|
||||||
@@ -87,21 +81,13 @@
|
|||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "Nothing in the queue.",
|
"queue": "Nothing in the queue.",
|
||||||
"done": "No downloads yet.",
|
|
||||||
"shared": "Nothing has been shared with you.",
|
|
||||||
"admin": "No downloads on this instance yet."
|
"admin": "No downloads on this instance yet."
|
||||||
},
|
},
|
||||||
"usage": {
|
|
||||||
"title": "Your storage",
|
|
||||||
"items": "{{used}} / {{max}} downloads",
|
|
||||||
"unlimited": "Unlimited"
|
|
||||||
},
|
|
||||||
"source": {
|
"source": {
|
||||||
"open": "Open the original page in a new tab"
|
"open": "Open the original page in a new tab"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit details",
|
"title": "Edit details",
|
||||||
"name": "Title",
|
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"channelUrl": "Channel link",
|
"channelUrl": "Channel link",
|
||||||
"extraLinks": "Extra links",
|
"extraLinks": "Extra links",
|
||||||
|
|||||||
@@ -97,7 +97,13 @@
|
|||||||
"videoReset": "Couldn't reset the video",
|
"videoReset": "Couldn't reset the video",
|
||||||
"watchLaterAdd": "Couldn't add it to Watch later",
|
"watchLaterAdd": "Couldn't add it to Watch later",
|
||||||
"watchLaterRemove": "Couldn't remove it from 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",
|
"ok": "OK",
|
||||||
"offline": {
|
"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",
|
"goPlaylists": "Go to Playlists",
|
||||||
"goPlex": "Go to Plex",
|
"goPlex": "Go to Plex",
|
||||||
"goNotifications": "Go to Notifications",
|
"goNotifications": "Go to Notifications",
|
||||||
|
"goFiles": "Go to Files",
|
||||||
"goDownloads": "Go to Downloads",
|
"goDownloads": "Go to Downloads",
|
||||||
"goStats": "Go to Stats",
|
"goStats": "Go to Stats",
|
||||||
"goSettings": "Go to Settings",
|
"goSettings": "Go to Settings",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"plex": "Plex",
|
"plex": "Plex",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
|
"files": "Files",
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"scheduler": "Scheduler",
|
"scheduler": "Scheduler",
|
||||||
|
|||||||
@@ -41,8 +41,6 @@
|
|||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"queue": "Sor",
|
"queue": "Sor",
|
||||||
"done": "Könyvtár",
|
|
||||||
"shared": "Velem megosztva",
|
|
||||||
"system": "Rendszer"
|
"system": "Rendszer"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -51,8 +49,7 @@
|
|||||||
"paused": "Szüneteltetve",
|
"paused": "Szüneteltetve",
|
||||||
"done": "Kész",
|
"done": "Kész",
|
||||||
"error": "Sikertelen",
|
"error": "Sikertelen",
|
||||||
"canceled": "Megszakítva",
|
"canceled": "Megszakítva"
|
||||||
"expired": "Lejárt"
|
|
||||||
},
|
},
|
||||||
"phase": {
|
"phase": {
|
||||||
"video": "Videósáv letöltése",
|
"video": "Videósáv letöltése",
|
||||||
@@ -75,9 +72,6 @@
|
|||||||
"download": "Mentés a gépemre",
|
"download": "Mentés a gépemre",
|
||||||
"play": "Lejátszás",
|
"play": "Lejátszás",
|
||||||
"rename": "Átnevezé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…",
|
"edit": "Szerkesztés / vágás…",
|
||||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||||
"removeShared": "Eltávolítás a listámból"
|
"removeShared": "Eltávolítás a listámból"
|
||||||
@@ -87,21 +81,13 @@
|
|||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "A sor üres.",
|
"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."
|
"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": {
|
"source": {
|
||||||
"open": "Eredeti oldal megnyitása új lapon"
|
"open": "Eredeti oldal megnyitása új lapon"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Adatok szerkesztése",
|
"title": "Adatok szerkesztése",
|
||||||
"name": "Cím",
|
|
||||||
"channel": "Csatorna",
|
"channel": "Csatorna",
|
||||||
"channelUrl": "Csatorna link",
|
"channelUrl": "Csatorna link",
|
||||||
"extraLinks": "További linkek",
|
"extraLinks": "További linkek",
|
||||||
|
|||||||
@@ -97,7 +97,13 @@
|
|||||||
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
|
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
|
||||||
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
|
"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",
|
"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",
|
"ok": "OK",
|
||||||
"offline": {
|
"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",
|
"goPlaylists": "Ugrás: Lejátszási listák",
|
||||||
"goPlex": "Ugrás: Plex",
|
"goPlex": "Ugrás: Plex",
|
||||||
"goNotifications": "Ugrás: Értesítések",
|
"goNotifications": "Ugrás: Értesítések",
|
||||||
|
"goFiles": "Ugrás a Fájlokhoz",
|
||||||
"goDownloads": "Ugrás: Letöltések",
|
"goDownloads": "Ugrás: Letöltések",
|
||||||
"goStats": "Ugrás: Statisztika",
|
"goStats": "Ugrás: Statisztika",
|
||||||
"goSettings": "Ugrás: Beállítások",
|
"goSettings": "Ugrás: Beállítások",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"playlists": "Listák",
|
"playlists": "Listák",
|
||||||
"plex": "Plex",
|
"plex": "Plex",
|
||||||
"notifications": "Értesítések",
|
"notifications": "Értesítések",
|
||||||
|
"files": "Fájlok",
|
||||||
"downloads": "Letöltések",
|
"downloads": "Letöltések",
|
||||||
"stats": "Statisztika",
|
"stats": "Statisztika",
|
||||||
"scheduler": "Ütemező",
|
"scheduler": "Ütemező",
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export {
|
|||||||
} from "./api/plex";
|
} from "./api/plex";
|
||||||
import { downloadsApi } from "./api/downloads";
|
import { downloadsApi } from "./api/downloads";
|
||||||
export type * from "./api/downloads";
|
export type * from "./api/downloads";
|
||||||
|
import { filesApi } from "./api/files";
|
||||||
|
export type * from "./api/files";
|
||||||
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
|
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
|
||||||
export type * from "./api/diagnostics";
|
export type * from "./api/diagnostics";
|
||||||
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
|
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
|
||||||
@@ -80,6 +82,7 @@ export const api = {
|
|||||||
...adminApi,
|
...adminApi,
|
||||||
...plexApi,
|
...plexApi,
|
||||||
...downloadsApi,
|
...downloadsApi,
|
||||||
|
...filesApi,
|
||||||
...diagnosticsApi,
|
...diagnosticsApi,
|
||||||
...debugSessionsApi,
|
...debugSessionsApi,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock("../activeAccount", () => ({
|
|||||||
|
|
||||||
import { setDebugSession } from "../log";
|
import { setDebugSession } from "../log";
|
||||||
import { downloadsApi } from "./downloads";
|
import { downloadsApi } from "./downloads";
|
||||||
|
import { filesApi } from "./files";
|
||||||
import {
|
import {
|
||||||
beacon,
|
beacon,
|
||||||
req,
|
req,
|
||||||
@@ -48,10 +49,13 @@ class FakeXHR {
|
|||||||
onabort: (() => void) | null = null;
|
onabort: (() => void) | null = null;
|
||||||
sent = false;
|
sent = false;
|
||||||
body: unknown = null;
|
body: unknown = null;
|
||||||
|
url = "";
|
||||||
constructor() {
|
constructor() {
|
||||||
FakeXHR.last = this;
|
FakeXHR.last = this;
|
||||||
}
|
}
|
||||||
open() {}
|
open(_method: string, url: string) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
setRequestHeader(k: string, v: string) {
|
setRequestHeader(k: string, v: string) {
|
||||||
// A real XHR COMBINES values for a name it already has, case-insensitively, rather than
|
// 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
|
// 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
|
// 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
|
// `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.
|
// 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", () => {
|
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" });
|
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;
|
const x = FakeXHR.last;
|
||||||
expect(x.body).toBe(file);
|
expect(x.body).toBe(file);
|
||||||
expect(x.body).not.toBeInstanceOf(FormData);
|
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", () => {
|
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-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
|
// 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
|
// 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.
|
// 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 {
|
export interface DownloadSpec {
|
||||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
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.
|
// 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;
|
edit_spec?: EditSpec | Omit<ClipRequest, "display_name"> | null;
|
||||||
can_download: boolean;
|
can_download: boolean;
|
||||||
expired: boolean;
|
|
||||||
// The Files library (backend downloads/folders): its folder, whether the user keeps it past the
|
// 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's retention, and when it goes (null = never).
|
||||||
folder_id: number | null;
|
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.
|
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
|
||||||
export const DISPLAY_NAME_MAX = 255;
|
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 {
|
export interface AdminStorage {
|
||||||
files_on_disk: number;
|
files_on_disk: number;
|
||||||
total_bytes: number;
|
total_bytes: number;
|
||||||
@@ -214,29 +199,6 @@ export const downloadsApi = {
|
|||||||
spec?: DownloadSpec;
|
spec?: DownloadSpec;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
}): 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: {
|
enqueueEdit: (body: {
|
||||||
source_job_id: number;
|
source_job_id: number;
|
||||||
edit_spec: EditSpec;
|
edit_spec: EditSpec;
|
||||||
@@ -250,11 +212,6 @@ export const downloadsApi = {
|
|||||||
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
||||||
req(`/api/downloads/${id}/storyboard`),
|
req(`/api/downloads/${id}/storyboard`),
|
||||||
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
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"),
|
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
|
||||||
pauseDownload: (id: number): Promise<DownloadJob> =>
|
pauseDownload: (id: number): Promise<DownloadJob> =>
|
||||||
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
|
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.
|
// deletes the old file, and rebuilds it under the current format/naming rules.
|
||||||
redownloadDownload: (id: number): Promise<DownloadJob> =>
|
redownloadDownload: (id: number): Promise<DownloadJob> =>
|
||||||
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
|
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 }> =>
|
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
|
||||||
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
unshareDownload: (id: number, email: string) =>
|
unshareDownload: (id: number, email: string) =>
|
||||||
@@ -330,14 +275,6 @@ export const downloadsApi = {
|
|||||||
},
|
},
|
||||||
onSettled,
|
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).
|
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
|
||||||
storyboardImageUrl: (id: number): string => {
|
storyboardImageUrl: (id: number): string => {
|
||||||
const a = getActiveAccount();
|
const a = getActiveAccount();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const SLICE_MODULES = [
|
|||||||
["adminApi", () => import("./admin")],
|
["adminApi", () => import("./admin")],
|
||||||
["plexApi", () => import("./plex")],
|
["plexApi", () => import("./plex")],
|
||||||
["downloadsApi", () => import("./downloads")],
|
["downloadsApi", () => import("./downloads")],
|
||||||
|
["filesApi", () => import("./files")],
|
||||||
["diagnosticsApi", () => import("./diagnostics")],
|
["diagnosticsApi", () => import("./diagnostics")],
|
||||||
["debugSessionsApi", () => import("./diagnostics")],
|
["debugSessionsApi", () => import("./diagnostics")],
|
||||||
] as const;
|
] 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 { notify } from "./notifications";
|
||||||
import { notifyActionError } from "./actionErrors";
|
import { notifyActionError } from "./actionErrors";
|
||||||
|
|
||||||
// The download queue, the per-card download index, and the storage-usage meter all move together
|
// The download queue, the per-card download index, the library (its listings and folder counts) and
|
||||||
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
// 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) {
|
export function invalidateDownloads(qc: QueryClient) {
|
||||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
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.
|
// 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",
|
labelKey: "keys.goNotifications",
|
||||||
page: "notifications",
|
page: "notifications",
|
||||||
},
|
},
|
||||||
|
{ id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" },
|
||||||
{
|
{
|
||||||
id: "goDownloads",
|
id: "goDownloads",
|
||||||
scope: "page",
|
scope: "page",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ListVideo,
|
ListVideo,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
|
FolderOpen,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Tv,
|
Tv,
|
||||||
@@ -88,6 +89,12 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
icon: Bell,
|
icon: Bell,
|
||||||
hasBadge: true,
|
hasBadge: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "files",
|
||||||
|
labelKey: "files.navLabel",
|
||||||
|
railKey: "nav.rail.files",
|
||||||
|
icon: FolderOpen,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "downloads",
|
id: "downloads",
|
||||||
labelKey: "downloads.navLabel",
|
labelKey: "downloads.navLabel",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const EXPECTED: Record<Page, string> = {
|
|||||||
channels: "header.channelManager",
|
channels: "header.channelManager",
|
||||||
playlists: "header.account.playlists",
|
playlists: "header.account.playlists",
|
||||||
notifications: "inbox.navLabel",
|
notifications: "inbox.navLabel",
|
||||||
|
files: "files.navLabel",
|
||||||
downloads: "downloads.navLabel",
|
downloads: "downloads.navLabel",
|
||||||
stats: "header.usageStats",
|
stats: "header.usageStats",
|
||||||
plex: "plex.navLabel",
|
plex: "plex.navLabel",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Per-user customization of a side panel's group cards: their order, which are collapsed, and
|
// 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
|
// 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.
|
// the server-side `preferences` blob (schemaless), so it follows the account across devices.
|
||||||
import { LS, readAccount, writeAccount } from "./storage";
|
import { LS, readAccount, writeAccount } from "./storage";
|
||||||
|
|
||||||
export type PanelId = "feed" | "plex" | "playlists";
|
export type PanelId = "feed" | "plex" | "playlists" | "files";
|
||||||
|
|
||||||
export interface PanelLayout {
|
export interface PanelLayout {
|
||||||
order: string[];
|
order: string[];
|
||||||
@@ -30,6 +30,7 @@ const PANEL_ORDER: Record<PanelId, string[]> = {
|
|||||||
"contentrating",
|
"contentrating",
|
||||||
],
|
],
|
||||||
playlists: ["manage", "list"],
|
playlists: ["manage", "list"],
|
||||||
|
files: ["library", "sources", "own"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// localStorage key per panel (feed keeps its original key for backward compatibility).
|
// 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,
|
feed: LS.sidebarLayout,
|
||||||
plex: LS.plexLayout,
|
plex: LS.plexLayout,
|
||||||
playlists: LS.playlistsLayout,
|
playlists: LS.playlistsLayout,
|
||||||
|
files: LS.filesLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Key on the server `preferences` blob per panel (feed keeps its original key).
|
// 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",
|
feed: "sidebarLayout",
|
||||||
plex: "plexLayout",
|
plex: "plexLayout",
|
||||||
playlists: "playlistsLayout",
|
playlists: "playlistsLayout",
|
||||||
|
files: "filesLayout",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function defaultLayout(panel: PanelId): PanelLayout {
|
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
|
// 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
|
// 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 ---
|
// --- sharing ---
|
||||||
shareRecipients: () => ["share-recipients"] as const,
|
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: () => ["downloads"] as const,
|
downloads: () => ["downloads"] as const,
|
||||||
downloadsShared: () => ["downloads-shared"] as const,
|
|
||||||
downloadIndex: () => ["download-index"] as const,
|
downloadIndex: () => ["download-index"] as const,
|
||||||
downloadUsage: () => ["download-usage"] as const,
|
|
||||||
downloadProfiles: () => ["download-profiles"] as const,
|
downloadProfiles: () => ["download-profiles"] as const,
|
||||||
downloadCredentials: () => ["download-credentials"] as const,
|
downloadCredentials: () => ["download-credentials"] as const,
|
||||||
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export const LS = {
|
|||||||
plexLayout: "siftlode.plexLayout",
|
plexLayout: "siftlode.plexLayout",
|
||||||
playlistsLayout: "siftlode.playlistsLayout",
|
playlistsLayout: "siftlode.playlistsLayout",
|
||||||
playlistsCollapsed: "siftlode.playlistsCollapsed",
|
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",
|
hints: "siftlode.hints",
|
||||||
lang: "siftlode.lang",
|
lang: "siftlode.lang",
|
||||||
filters: "siftlode.filters",
|
filters: "siftlode.filters",
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ const PAGES = [
|
|||||||
"audit",
|
"audit",
|
||||||
"logs",
|
"logs",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"files",
|
||||||
"downloads",
|
"downloads",
|
||||||
"plex",
|
"plex",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
+22
-19
@@ -10,6 +10,7 @@ import { NavigationProvider } from "./components/NavigationProvider";
|
|||||||
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
||||||
import { SavedViewsProvider } from "./components/SavedViewsProvider";
|
import { SavedViewsProvider } from "./components/SavedViewsProvider";
|
||||||
import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
||||||
|
import { FilesProvider } from "./components/FilesProvider";
|
||||||
import { PlexProvider } from "./components/PlexProvider";
|
import { PlexProvider } from "./components/PlexProvider";
|
||||||
import { PlexViewBridge } from "./components/PlexViewBridge";
|
import { PlexViewBridge } from "./components/PlexViewBridge";
|
||||||
import { ChannelsProvider } from "./components/ChannelsProvider";
|
import { ChannelsProvider } from "./components/ChannelsProvider";
|
||||||
@@ -81,27 +82,29 @@ const root =
|
|||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<FeedFiltersProvider>
|
<FeedFiltersProvider>
|
||||||
<PlaylistsProvider>
|
<PlaylistsProvider>
|
||||||
<PlexProvider>
|
<FilesProvider>
|
||||||
<PlexViewBridge>
|
<PlexProvider>
|
||||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
<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. */}
|
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||||
<SavedViewsProvider>
|
<SavedViewsProvider>
|
||||||
<ChannelsProvider>
|
<ChannelsProvider>
|
||||||
<FeedViewProvider>
|
<FeedViewProvider>
|
||||||
<PrefsProvider>
|
<PrefsProvider>
|
||||||
<WizardProvider>
|
<WizardProvider>
|
||||||
{/* The same quiet screen as the gate's, so a chunk that is still
|
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||||
loading when the gate opens continues it instead of blanking. */}
|
loading when the gate opens continues it instead of blanking. */}
|
||||||
<Suspense fallback={<BootScreen />}>
|
<Suspense fallback={<BootScreen />}>
|
||||||
<App />
|
<App />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WizardProvider>
|
</WizardProvider>
|
||||||
</PrefsProvider>
|
</PrefsProvider>
|
||||||
</FeedViewProvider>
|
</FeedViewProvider>
|
||||||
</ChannelsProvider>
|
</ChannelsProvider>
|
||||||
</SavedViewsProvider>
|
</SavedViewsProvider>
|
||||||
</PlexViewBridge>
|
</PlexViewBridge>
|
||||||
</PlexProvider>
|
</PlexProvider>
|
||||||
|
</FilesProvider>
|
||||||
</PlaylistsProvider>
|
</PlaylistsProvider>
|
||||||
</FeedFiltersProvider>
|
</FeedFiltersProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user