feat(files): the Files page — the library in one aligned list, its folders in a rail

Files is the one library: every done file, the ones shared with me, or one
folder's, picked in a side rail (sources, own folders, the storage meter) and
mirrored to ?folder=. One table with every kind of value in its own column;
Copy link, Share and Save in sight, every action on a right click: play,
cut, rename in the row, move to an own folder or back home, keep, details,
re-download, delete.

The Downloads page keeps only the queue until the tray replaces it. The old
/api/downloads library routes are gone: list shared, upload, the details
PATCH, delete, the file and the usage now live under /api/files, and a
listed file says which source it came from. The job's "expired" flag is gone
with the state it described.
This commit is contained in:
2026-10-11 05:34:25 +02:00
parent 1a1665b8ec
commit 85aa917b38
65 changed files with 2295 additions and 1044 deletions
+15 -4
View File
@@ -24,6 +24,7 @@ vi.mock("../activeAccount", () => ({
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
import { filesApi } from "./files";
import {
beacon,
req,
@@ -48,10 +49,13 @@ class FakeXHR {
onabort: (() => void) | null = null;
sent = false;
body: unknown = null;
url = "";
constructor() {
FakeXHR.last = this;
}
open() {}
open(_method: string, url: string) {
this.url = url;
}
setRequestHeader(k: string, v: string) {
// A real XHR COMBINES values for a name it already has, case-insensitively, rather than
// overwriting — so a plain assignment here would hide exactly the duplicate-header bug the
@@ -470,10 +474,10 @@ describe("the debug session a tab records into", () => {
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
// backend's own test only sees the shape the client actually sends.
describe("uploadDownload's wire shape", () => {
describe("uploadFile's wire shape", () => {
it("sends the File as the raw body, with the names as percent-encoded headers", () => {
const file = new File(["abc"], "nyaralás videó.mp4", { type: "video/mp4" });
void downloadsApi.uploadDownload(file, { displayName: "Nyaralás 2026" });
void filesApi.uploadFile(file, { displayName: "Nyaralás 2026" });
const x = FakeXHR.last;
expect(x.body).toBe(file);
expect(x.body).not.toBeInstanceOf(FormData);
@@ -484,8 +488,15 @@ describe("uploadDownload's wire shape", () => {
});
it("omits the display-name header when there is no display name", () => {
void downloadsApi.uploadDownload(new File(["abc"], "clip.mp4"));
void filesApi.uploadFile(new File(["abc"], "clip.mp4"));
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBeUndefined();
});
it("names the folder it goes into, when it has one", () => {
void filesApi.uploadFile(new File(["abc"], "clip.mp4"), { folderId: 7 });
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBe("7");
expect(FakeXHR.last.url).toBe("/api/files/upload");
});
});
+1 -64
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { beacon, req, uploadReq, getActiveAccount } from "./core";
import { beacon, req, getActiveAccount } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -105,7 +105,6 @@ export interface DownloadJob {
// An edit's recipe, or a clip's spec (its request without the name) — by job_kind.
edit_spec?: EditSpec | Omit<ClipRequest, "display_name"> | null;
can_download: boolean;
expired: boolean;
// The Files library (backend downloads/folders): its folder, whether the user keeps it past the
// folder's retention, and when it goes (null = never).
folder_id: number | null;
@@ -142,20 +141,6 @@ export interface ShareLink {
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
export const DISPLAY_NAME_MAX = 255;
export interface DownloadUsage {
footprint_bytes: number;
active_jobs: number;
running_jobs: number;
max_bytes: number;
max_concurrent: number;
max_jobs: number;
unlimited: boolean;
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
upload_max_bytes: number;
}
export interface AdminStorage {
files_on_disk: number;
total_bytes: number;
@@ -214,29 +199,6 @@ export const downloadsApi = {
spec?: DownloadSpec;
display_name?: string;
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
// Ingest a local file into the library. Sent via XHR rather than `req` so the dialog can
// show upload progress; any file type is accepted (media gets the full function set, a generic
// file is download-only). Returns the job the ingest creates: `done`, or `queued` while the
// worker converts media no phone could play as it is (`downloads/upload.py`).
// The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is
// a DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency
// runs — so with the old shape an unauthenticated caller could make the server write the whole
// per-file allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`;
// the server percent-decodes both. Content-Type is left to the browser (the File's own type).
uploadDownload: (
file: File,
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
): Promise<DownloadJob> => {
const headers: Record<string, string> = {
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
};
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
return uploadReq<DownloadJob>("/api/downloads/upload", file, {
onProgress: opts.onProgress,
signal: opts.signal,
headers,
});
},
enqueueEdit: (body: {
source_job_id: number;
edit_spec: EditSpec;
@@ -250,11 +212,6 @@ export const downloadsApi = {
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
req(`/api/downloads/${id}/storyboard`),
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
// Remove a shared-with-me item from your list (deletes only your share grant, not the file).
removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
pauseDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
@@ -266,18 +223,6 @@ export const downloadsApi = {
// deletes the old file, and rebuilds it under the current format/naming rules.
redownloadDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
// Update a download's display metadata (title / channel name+link / extra reference URLs).
updateDownloadMeta: (
id: number,
meta: {
display_name?: string;
display_uploader?: string;
display_uploader_url?: string;
extra_links?: string[];
},
): Promise<DownloadJob> =>
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
unshareDownload: (id: number, email: string) =>
@@ -330,14 +275,6 @@ export const downloadsApi = {
},
onSettled,
),
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
// account via ?account= (the server honours it only for an account in this browser's wallet) —
// otherwise the download resolves to the session-default account and 404s for a per-tab
// account's file.
downloadFileUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
},
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
storyboardImageUrl: (id: number): string => {
const a = getActiveAccount();
+1
View File
@@ -32,6 +32,7 @@ const SLICE_MODULES = [
["adminApi", () => import("./admin")],
["plexApi", () => import("./plex")],
["downloadsApi", () => import("./downloads")],
["filesApi", () => import("./files")],
["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
] as const;
+123
View File
@@ -0,0 +1,123 @@
// The Files library: a user's done files, the folders they sit in, and the file itself
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
import { getActiveAccount, req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
export type FolderKind = "source" | "own";
export interface FileFolder {
id: number;
kind: FolderKind;
// A source folder's origin — `youtube`, `plex`, `upload`, `site:<host>` — which the client names
// in the reader's language; null on an own folder.
source_key: string | null;
name: string | null; // an own folder's name; null on a source folder
retention_days: number | null; // null = kept until deleted
count: number; // its done files
size_bytes: number;
created_at: string | null;
}
export type FileSort = "added" | "name" | "size";
export type FileOrder = "asc" | "desc";
export interface FileListParams {
folderId?: number | null; // one folder's files; absent = every file
q?: string;
sort?: FileSort;
order?: FileOrder;
shared?: boolean; // the files others shared with me instead of mine
}
export interface LibraryFile extends DownloadJob {
// When it was shared with me (the "added" of a shared file); null on my own.
shared_at?: string | null;
// Where it came from — its source folder's key (`youtube`, `site:<host>`, …), wherever it sits now.
source_key: string;
}
// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
export interface FilePatch {
name?: string | null;
uploader?: string | null;
uploader_url?: string | null;
extra_links?: string[];
folder_id?: number;
kept?: boolean;
}
export interface StorageUsage {
footprint_bytes: number;
active_jobs: number;
running_jobs: number;
max_bytes: number;
max_concurrent: number;
max_jobs: number;
unlimited: boolean;
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
upload_max_bytes: number;
}
function listQuery(p: FileListParams): string {
const q = new URLSearchParams();
if (p.shared) q.set("scope", "shared");
else if (p.folderId != null) q.set("folder_id", String(p.folderId));
if (p.q?.trim()) q.set("q", p.q.trim());
if (p.sort) q.set("sort", p.sort);
if (p.order) q.set("order", p.order);
const s = q.toString();
return s ? `?${s}` : "";
}
export const filesApi = {
fileFolders: (): Promise<FileFolder[]> => req("/api/files/folders"),
files: (p: FileListParams = {}): Promise<LibraryFile[]> => req(`/api/files${listQuery(p)}`),
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
req(`/api/files/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteFile: (id: number): Promise<{ ok: true; deleted: number }> =>
req(`/api/files/${id}`, { method: "DELETE" }),
// Drop a file someone shared with me from my list (only my grant goes, not their file).
removeSharedFile: (id: number): Promise<{ ok: true; removed: number }> =>
req(`/api/files/shared/${id}`, { method: "DELETE" }),
// Put a local file in the library. Sent via XHR rather than `req` so the dialog can show upload
// progress; any file type is accepted (media gets the full function set, a generic file is
// download-only). Returns the job the ingest creates: `done`, or `queued` while the worker
// converts media no phone could play as it is (`downloads/upload.py`).
// The body IS the file, and the names ride as headers. Not a style choice: a multipart part is a
// DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency runs
// — so with that shape an unauthenticated caller could make the server write the whole per-file
// allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`; the server
// percent-decodes them. Content-Type is left to the browser (the File's own type). No folder =
// the Uploads folder.
uploadFile: (
file: File,
opts: {
displayName?: string;
folderId?: number | null;
onProgress?: (pct: number) => void;
signal?: AbortSignal;
} = {},
): Promise<DownloadJob> => {
const headers: Record<string, string> = {
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
};
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
if (opts.folderId != null) headers["X-Upload-Folder"] = String(opts.folderId);
return uploadReq<DownloadJob>("/api/files/upload", file, {
onProgress: opts.onProgress,
signal: opts.signal,
headers,
});
},
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
// file resolves to the session-default account and 404s for a per-tab account's file.
fileUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
};