// 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 { 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; // "hub": the one folder the user swaps files with their hub machine through (made by its token). export type FolderKind = "source" | "own" | "hub"; export interface FileFolder { id: number; kind: FolderKind; // A source folder's origin — `youtube`, `plex`, `upload`, `site:` — 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; // A live public link opens it: whatever is moved in is shown to whoever holds the link. has_link: boolean; } // The most files one list answers with (backend `routes/files.py` MAX_LIST, held equal by // files.contract.test.ts): a list this long may be missing older files, so the page asks the // server to search and sort it rather than doing either on the rows it got. export const FILES_LIST_CAP = 500; export type FileSort = "added" | "name" | "size"; export const FILE_SORTS: readonly 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:`, …), wherever it sits now. source_key: string; } // Set by the server on a text preview cut at its size cap (`routes/files.py` TRUNCATED_HEADER). const PREVIEW_TRUNCATED_HEADER = "X-Preview-Truncated"; export interface FilePreviewText { text: string; truncated: boolean; } // 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; } /** The list's query string — exported for the contract test that holds its names to the route's. */ export 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}` : ""; } // What a folder PATCH may change. Presence decides: `retention_days: null` = keep its files forever. export interface FolderPatch { name?: string; // an own folder's only retention_days?: number | null; } export const filesApi = { fileFolders: (): Promise => req("/api/files/folders"), createFolder: (name: string): Promise => req("/api/files/folders", { method: "POST", body: JSON.stringify({ name }) }), updateFolder: (id: number, patch: FolderPatch): Promise => req(`/api/files/folders/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), // An own folder only; its files go back to the folders they came from. deleteFolder: (id: number): Promise<{ ok: true; deleted: number; files_moved: number }> => req(`/api/files/folders/${id}`, { method: "DELETE" }), files: (p: FileListParams = {}): Promise => req(`/api/files${listQuery(p)}`), filesUsage: (): Promise => req("/api/files/usage"), updateFile: (id: number, patch: FilePatch): Promise => 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 => { const headers: Record = { "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("/api/files/upload", file, { onProgress: opts.onProgress, signal: opts.signal, headers, }); }, // The file itself, for a plain navigation. fileUrl: (id: number): string => `/api/files/${id}/file`, // The viewer's copy of an image or a PDF (`/preview`, sandboxed): an /