feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text

- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
This commit is contained in:
2026-10-12 15:28:31 +02:00
parent 6707550513
commit fb8fb980e5
26 changed files with 1090 additions and 48 deletions
@@ -36,3 +36,33 @@ describe("display-name bound", () => {
for (const m of declared) expect(m[1]).toBe("DISPLAY_TEXT_MAX");
});
});
// The viewer reads what the server decides (backend downloads/preview.py): the preview kinds it may
// be told and the header that says a text was cut. Each side names them in its own language, so the
// gate reads both: a kind the server adds that the viewer does not know would fall to "no preview".
describe("file preview", () => {
it("knows the kinds the server hands out", () => {
const backend = src("../../../../backend/app/downloads/preview.py").match(
/^PreviewKind = Literal\[(.*)\]$/m,
);
const frontend = src("./downloads.ts").match(/^export type FilePreviewKind = (.*);$/m);
expect(backend, "downloads/preview.py no longer defines PreviewKind").toBeTruthy();
expect(frontend, "lib/api/downloads.ts no longer exports FilePreviewKind").toBeTruthy();
const kinds = (s: string, sep: string) =>
s
.split(sep)
.map((k) => k.trim().replace(/"/g, ""))
.sort();
expect(kinds(frontend![1]!, "|")).toEqual(kinds(backend![1]!, ","));
});
it("reads the header the server cuts a text with", () => {
const backend = src("../../../../backend/app/routes/files.py").match(
/^TRUNCATED_HEADER = "(.*)"$/m,
);
const frontend = src("./files.ts").match(/^const PREVIEW_TRUNCATED_HEADER = "(.*)";$/m);
expect(backend, "routes/files.py no longer defines TRUNCATED_HEADER").toBeTruthy();
expect(frontend, "lib/api/files.ts no longer defines PREVIEW_TRUNCATED_HEADER").toBeTruthy();
expect(frontend![1]).toBe(backend![1]);
});
});
+5
View File
@@ -39,9 +39,14 @@ interface DownloadAsset {
// (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null
// while still in flight (set at finalize). See backend downloads/mediakind.py.
media_kind: "video" | "audio" | "file" | null;
// What the library's viewer shows of a generic file (backend downloads/preview.py): an image, a PDF
// in the browser's own viewer, plain text; null = no preview (media plays instead).
preview?: FilePreviewKind | null;
thumbnail_url: string | null;
}
export type FilePreviewKind = "image" | "pdf" | "text";
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload"
// (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight
+19
View File
@@ -37,6 +37,14 @@ export interface LibraryFile extends DownloadJob {
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;
@@ -133,4 +141,15 @@ export const filesApi = {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
// carries the account the same way.
previewUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
},
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
previewText: async (id: number): Promise<FilePreviewText> => {
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
return { text: await r.text(), truncated: r.headers.get(PREVIEW_TRUNCATED_HEADER) === "1" };
},
};
+19 -6
View File
@@ -1,5 +1,5 @@
// The Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
// The Library/Shared row-action gate. A generic uploaded file (media_kind='file') opens in the viewer
// instead of a player (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
// is caught (the affordance itself, not just the backend guard behind it).
import { describe, it, expect } from "vitest";
@@ -31,14 +31,16 @@ describe("libraryRowActions", () => {
it("a media (video) item gets play, edit, details, redownload", () => {
expect(libraryRowActions(job({}, {}))).toEqual({
play: true,
view: false,
edit: true,
editDetails: true,
redownload: true,
});
});
it("a generic file gets NO play/edit/details (download+share only)", () => {
it("a generic file opens in the viewer and gets NO play/edit/details", () => {
const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null }));
expect(a.view).toBe(true);
expect(a.play).toBe(false);
expect(a.edit).toBe(false);
expect(a.editDetails).toBe(false);
@@ -62,19 +64,30 @@ describe("libraryRowActions", () => {
describe("sharedRowActions", () => {
it("a downloadable media item gets play, save, edit", () => {
expect(sharedRowActions(job({}, {}))).toEqual({ play: true, save: true, edit: true });
expect(sharedRowActions(job({}, {}))).toEqual({
play: true,
view: false,
save: true,
edit: true,
});
});
it("nothing is offered when the owner disabled download", () => {
expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({
play: false,
view: false,
save: false,
edit: false,
});
});
it("a shared generic file gets only save (no player/trim)", () => {
it("a shared generic file opens in the viewer and saves (no player/trim)", () => {
const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null }));
expect(a).toEqual({ play: false, save: true, edit: false });
expect(a).toEqual({ play: false, view: true, save: true, edit: false });
});
it("a shared generic file its owner keeps from me does not open either", () => {
const a = sharedRowActions(job({ can_download: false }, { media_kind: "file" }));
expect(a.view).toBe(false);
});
});
+7 -2
View File
@@ -1,8 +1,8 @@
import type { DownloadJob } from "./api";
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
// job_kind. A generic uploaded file (media_kind='file') is download + share only: no player,
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
// job_kind. A generic uploaded file (media_kind='file') opens in the Files viewer instead of a player,
// and has no trim and no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
@@ -11,6 +11,8 @@ const hasDuration = (job: DownloadJob): boolean => (job.asset?.duration_s ?? 0)
export interface LibraryRowActions {
play: boolean;
/** Opens in the Files viewer: a file that is not media (its preview, or the "no preview" card). */
view: boolean;
edit: boolean;
editDetails: boolean;
redownload: boolean;
@@ -20,6 +22,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
const media = isMediaKind(job);
return {
play: media,
view: !media && job.can_download,
edit: media && job.can_download && hasDuration(job),
editDetails: media,
redownload: !job.job_kind || job.job_kind === "download",
@@ -28,6 +31,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
export interface SharedRowActions {
play: boolean;
view: boolean;
save: boolean;
edit: boolean;
}
@@ -37,6 +41,7 @@ export function sharedRowActions(job: DownloadJob): SharedRowActions {
// The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
return {
play: media && job.can_download,
view: !media && job.can_download,
save: job.can_download,
edit: media && job.can_download && hasDuration(job),
};
+5
View File
@@ -37,6 +37,7 @@ export type KeyScope =
| "ytPlayer"
| "plexPlayer"
| "mediaPlayer"
| "fileViewer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -234,6 +235,10 @@ export const SHORTCUTS = [
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Files viewer: through the list's files that are not media ---------------------------
{ id: "prevFile", scope: "fileViewer", keys: ["ArrowLeft"], labelKey: "keys.file" },
{ id: "nextFile", scope: "fileViewer", keys: ["ArrowRight"], labelKey: "keys.file" },
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
+1
View File
@@ -68,6 +68,7 @@ export const qk = {
// 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,
filePreview: (id: number) => ["file-preview", id] as const,
storageUsage: () => ["storage-usage"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,