Deep-review remediation on the frontend, all findings adversarially verified: - LibraryPlayer picks audio vs video from the canonical media_kind (single source), not a hardcoded container whitelist, so an uploaded audio file with an uncommon/absent container renders in <audio> instead of a broken video box (legacy null media_kind keeps the heuristic). - UploadDialog aborts the in-flight upload when the dialog is closed by X/ESC/backdrop/Back (not only the footer Cancel); drag highlight uses an enter/leave depth counter to stop the flicker; the progress bar gets a stable role=progressbar aria-label with the value on aria-valuenow. - DownloadCenter's Shared tab renders honest loading/error states instead of a blank panel, and the library/shared row-action gating moves to a single tested helper (lib/downloadActions). Tests: downloadActions gating matrix, the uploadReq XHR path (abort/401/422-localize/onerror), and the media_kind-driven audio/video branch. i18n: downloads.upload.uploadProgress (HU + EN).
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// E7·S4b — 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, cleanup } from "@testing-library/react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
|
});
|
|
|
|
import LibraryPlayer from "./LibraryPlayer";
|
|
import type { DownloadJob } from "../lib/api";
|
|
|
|
const baseJob: DownloadJob = {
|
|
id: 7,
|
|
status: "done",
|
|
progress: 100,
|
|
speed_bps: null,
|
|
eta_s: null,
|
|
phase: null,
|
|
error: null,
|
|
error_code: 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,
|
|
expired: false,
|
|
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,
|
|
expires_at: null,
|
|
...over,
|
|
},
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
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/downloads/7/file");
|
|
// No <audio> element for a video asset.
|
|
expect(document.body.querySelector("audio")).toBeNull();
|
|
});
|
|
|
|
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/downloads/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();
|
|
});
|
|
});
|