- 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
138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A job's picture (the Files list and grid): with none at all, a quiet glyph of what it is; a remote
|
|
// thumbnail that fails falls back to the same glyph. i18n is key-passthrough here.
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { render, cleanup, fireEvent } from "@testing-library/react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
};
|
|
});
|
|
|
|
import { JobThumb } from "./downloadRow";
|
|
import type { DownloadJob } from "../lib/api";
|
|
|
|
const baseJob: DownloadJob = {
|
|
id: 1,
|
|
status: "done",
|
|
progress: 100,
|
|
speed_bps: null,
|
|
eta_s: null,
|
|
phase: null,
|
|
error: null,
|
|
error_code: null,
|
|
error_detail: null,
|
|
queue_pos: 0,
|
|
created_at: null,
|
|
source_kind: "youtube",
|
|
source_ref: "abc",
|
|
source_url: null,
|
|
poster_url: null,
|
|
profile_id: null,
|
|
spec: {
|
|
mode: "av",
|
|
max_height: null,
|
|
container: null,
|
|
audio_format: null,
|
|
vcodec: null,
|
|
embed_subs: false,
|
|
embed_chapters: false,
|
|
embed_thumbnail: false,
|
|
sponsorblock: false,
|
|
},
|
|
display_name: "Clip",
|
|
display_uploader: null,
|
|
display_uploader_url: null,
|
|
extra_links: [],
|
|
can_download: true,
|
|
folder_id: null,
|
|
kept: false,
|
|
expires_at: null,
|
|
asset: null,
|
|
};
|
|
|
|
const withAsset = (
|
|
container: string | null,
|
|
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
|
|
): DownloadJob => ({
|
|
...baseJob,
|
|
asset: {
|
|
id: 10,
|
|
status: "ready",
|
|
title: "Clip",
|
|
uploader: null,
|
|
uploader_url: null,
|
|
upload_date: null,
|
|
duration_s: 90,
|
|
size_bytes: 148_800_000,
|
|
width: 1920,
|
|
height: 1080,
|
|
container,
|
|
media_kind: "video",
|
|
thumbnail_url: null,
|
|
...over,
|
|
},
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
|
|
describe("JobThumb · no picture", () => {
|
|
it("draws a film glyph for a video, a note for audio", () => {
|
|
const { container, unmount } = render(<JobThumb job={withAsset("mp4")} />);
|
|
expect(container.querySelector("img")).toBeNull();
|
|
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
|
unmount();
|
|
const { container: audio } = render(
|
|
<JobThumb job={withAsset("m4a", { media_kind: "audio" })} />,
|
|
);
|
|
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
|
|
});
|
|
// The remote thumbnail was the last resort with no fallback of its own — when it
|
|
// failed too (a dead CDN link), the row drew the browser's broken-image mark.
|
|
it("falls back to the glyph when the remote picture fails too", () => {
|
|
const { container } = render(
|
|
<JobThumb job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
|
|
);
|
|
const img = container.querySelector("img")!;
|
|
expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg");
|
|
fireEvent.error(img);
|
|
expect(container.querySelector("img")).toBeNull();
|
|
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// A file that is not media: an image is its own picture (its sandboxed preview), unless it is too big
|
|
// for a thumbnail or fails; anything else draws the file glyph.
|
|
describe("JobThumb · a file", () => {
|
|
const image = (over: Partial<NonNullable<DownloadJob["asset"]>> = {}) =>
|
|
withAsset("png", { media_kind: "file", preview: "image", size_bytes: 200_000, ...over });
|
|
|
|
it("shows an image through its preview", () => {
|
|
const { container } = render(<JobThumb job={image()} />);
|
|
expect(new URL(container.querySelector("img")!.src).pathname).toBe("/api/files/1/preview");
|
|
});
|
|
|
|
it("keeps the glyph for an image too big to be a thumbnail, and for other files", () => {
|
|
const { container, unmount } = render(<JobThumb job={image({ size_bytes: 50_000_000 })} />);
|
|
expect(container.querySelector("img")).toBeNull();
|
|
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
|
|
unmount();
|
|
const { container: pdf } = render(
|
|
<JobThumb job={withAsset("pdf", { media_kind: "file", preview: "pdf" })} />,
|
|
);
|
|
expect(pdf.querySelector("img")).toBeNull();
|
|
});
|
|
|
|
it("falls back to the glyph when the image fails", () => {
|
|
const { container } = render(<JobThumb job={image()} />);
|
|
fireEvent.error(container.querySelector("img")!);
|
|
expect(container.querySelector("img")).toBeNull();
|
|
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
|
|
});
|
|
});
|