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.
238 lines
9.1 KiB
TypeScript
238 lines
9.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The in-app library player streams the completed item's LOCAL file into a native media
|
|
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
|
|
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
|
// is key-passthrough here.
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { render as rtlRender, cleanup } from "@testing-library/react";
|
|
import type { ReactElement } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
|
});
|
|
const expose = vi.hoisted(() => vi.fn());
|
|
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
|
|
|
import LibraryPlayer from "./LibraryPlayer";
|
|
import { api, type DownloadJob } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
|
|
const baseJob: DownloadJob = {
|
|
id: 7,
|
|
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 = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
|
|
...baseJob,
|
|
asset: {
|
|
id: 10,
|
|
status: "ready",
|
|
title: "Clip",
|
|
uploader: null,
|
|
uploader_url: null,
|
|
upload_date: null,
|
|
duration_s: 12,
|
|
size_bytes: 1000,
|
|
width: null,
|
|
height: null,
|
|
container: null,
|
|
media_kind: "video",
|
|
thumbnail_url: null,
|
|
...over,
|
|
},
|
|
});
|
|
|
|
// The player writes its resume point into the library listings' cache, so it sits under a client.
|
|
const render = (ui: ReactElement, client = new QueryClient()) =>
|
|
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
|
|
function loaded(duration = 600) {
|
|
const video = document.body.querySelector("video")!;
|
|
let t = 0;
|
|
Object.defineProperties(video, {
|
|
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
|
duration: { get: () => duration, configurable: true },
|
|
readyState: { get: () => 1, configurable: true },
|
|
});
|
|
video.dispatchEvent(new Event("loadedmetadata"));
|
|
return video;
|
|
}
|
|
|
|
describe("LibraryPlayer", () => {
|
|
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
|
render(
|
|
<LibraryPlayer
|
|
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const video = document.body.querySelector("video");
|
|
expect(video).not.toBeNull();
|
|
expect(video!.getAttribute("src")).toBe("/api/files/7/file");
|
|
// No <audio> element for a video asset.
|
|
expect(document.body.querySelector("audio")).toBeNull();
|
|
});
|
|
|
|
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", () => {
|
|
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
|
expect(expose).toHaveBeenCalledWith(document.body.querySelector("video"));
|
|
});
|
|
|
|
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
|
render(
|
|
<LibraryPlayer
|
|
job={withAsset({ media_kind: "audio", container: "m4a" })}
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const audio = document.body.querySelector("audio");
|
|
expect(audio).not.toBeNull();
|
|
expect(audio!.getAttribute("src")).toBe("/api/files/7/file");
|
|
expect(document.body.querySelector("video")).toBeNull();
|
|
});
|
|
|
|
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
|
|
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
|
|
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
|
|
render(
|
|
<LibraryPlayer
|
|
job={withAsset({ media_kind: "audio", container: "m4b" })}
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
expect(document.body.querySelector("audio")).not.toBeNull();
|
|
expect(document.body.querySelector("video")).toBeNull();
|
|
});
|
|
|
|
it("falls back to the container heuristic for a legacy null media_kind", () => {
|
|
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
|
|
render(
|
|
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
|
|
);
|
|
expect(document.body.querySelector("audio")).not.toBeNull();
|
|
});
|
|
|
|
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
|
|
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
|
|
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
|
|
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
|
|
this: HTMLElement,
|
|
) {
|
|
return this.parentElement;
|
|
});
|
|
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
|
const panel = document.activeElement as HTMLElement;
|
|
expect(panel.tagName).toBe("DIV");
|
|
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
|
|
});
|
|
|
|
it("opens where the viewer stopped — or from the start when asked", () => {
|
|
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
|
|
render(<LibraryPlayer job={job} onClose={() => {}} />);
|
|
expect(loaded().currentTime).toBe(300);
|
|
cleanup();
|
|
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
|
|
expect(loaded().currentTime).toBe(0);
|
|
});
|
|
|
|
it("keeps where it stopped: on the server, and on its row in every library list at once", () => {
|
|
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
|
|
ok: true,
|
|
job_id: 7,
|
|
position_seconds: 125,
|
|
});
|
|
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
|
|
const client = new QueryClient();
|
|
const job = withAsset({ width: 1280, height: 720 });
|
|
// The file sits in two lists at once: its folder's and the whole library's.
|
|
client.setQueryData(qk.files({ folderId: 1 }), [job, { ...job, id: 8 }]);
|
|
client.setQueryData(qk.files({}), [job]);
|
|
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
|
const video = loaded();
|
|
video.dispatchEvent(new Event("playing"));
|
|
video.currentTime = 125.4;
|
|
video.dispatchEvent(new Event("pause"));
|
|
|
|
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
|
|
const rows = client.getQueryData<DownloadJob[]>(qk.files({ folderId: 1 }))!;
|
|
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
|
|
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(125);
|
|
// Closing the player sends the point the way that survives the page going away.
|
|
unmount();
|
|
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
|
|
});
|
|
|
|
it("keeps the closing point over a list fetch that read the server before the point landed", async () => {
|
|
// The listing polls. One already out when the player closed answered with the point the server
|
|
// held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its
|
|
// first checkpoint wrote that older point back over the right one.
|
|
let settled: (() => void) | undefined;
|
|
vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => {
|
|
settled = onSettled;
|
|
});
|
|
const client = new QueryClient();
|
|
const job = withAsset({ width: 1280, height: 720 });
|
|
client.setQueryData(qk.files({}), [{ ...job, position_seconds: 10 }]);
|
|
let answer: (jobs: DownloadJob[]) => void = () => {};
|
|
const poll = client.fetchQuery({
|
|
queryKey: qk.files({}),
|
|
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
|
|
});
|
|
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
|
const video = loaded();
|
|
video.dispatchEvent(new Event("playing"));
|
|
video.currentTime = 300;
|
|
unmount();
|
|
answer([{ ...job, position_seconds: 10 }]);
|
|
await poll;
|
|
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(300);
|
|
// Once the point has landed, the listing asks the server again.
|
|
const invalidate = vi.spyOn(client, "invalidateQueries");
|
|
settled?.();
|
|
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
|
|
});
|
|
});
|