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