A failed Facebook download read "Download failed": yt-dlp words the login wall (18+ reel, closed group) as "Cannot parse data", which no category matched. Measured on dev with the owner's cookie before writing a rule — the same text is the login wall without a cookie and a refused cookie with one. - errors.diagnose(msg, url, cookie_attached) tries a host-scoped catalog first (all-of markers, cookie state, link fragment) and falls back to the category. First entries: facebook.login_wall, facebook.cookie_rejected, facebook.group_post_video. The module doc is the recipe for the next entry. - download_jobs.error_detail (0084) + backfill of the stored Facebook parse failures that were unknown. - The pipeline's failure branch passes the link and whether the cookie went with it; resume and redownload clear code and detail (redownload never cleared error_code). - A job meeting an already-failed asset got the text but no code, so it read "Download failed" where the first job said why. It is diagnosed again, with the cookie state from credentials.is_authed_asset (now shared with the cookie gate); the redacted network text classifies back to network. - FE: failCopyKeys gives the row and the failure window one lookup — catalog copy, else the category. Copy in EN and HU; a contract test pins every backend entry to both locales.
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Library-row metadata (E7·S1): the completed row surfaces the concrete container/format alongside the
|
|
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must
|
|
// render when present and be absent when the asset has no container yet (queued/running before the worker
|
|
// fills it). i18n is key-passthrough here.
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { render, screen, 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 { DownloadRow, IconBtn } 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,
|
|
expired: false,
|
|
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,
|
|
expires_at: null,
|
|
...over,
|
|
},
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("DownloadRow format chip", () => {
|
|
it("shows the uppercased container next to the size when ready", () => {
|
|
render(<DownloadRow job={withAsset("mp4")} />);
|
|
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
|
|
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
|
|
expect(screen.getByText(/142 MB/)).toBeTruthy();
|
|
});
|
|
|
|
it("omits the format chip when the asset has no container yet", () => {
|
|
const { container } = render(<DownloadRow job={withAsset(null)} />);
|
|
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
|
|
});
|
|
});
|
|
|
|
// E7·S5b — a generic uploaded file (media_kind='file') has no video frame and no timeline: the row
|
|
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
|
|
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
|
|
describe("DownloadRow file kind", () => {
|
|
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
|
|
const { container } = render(
|
|
<DownloadRow
|
|
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
|
|
/>,
|
|
);
|
|
// The file glyph is an inline svg; no <img> element is emitted for a file.
|
|
expect(container.querySelector("img")).toBeNull();
|
|
expect(container.querySelector("svg")).not.toBeNull();
|
|
// File info still reads (container + size), but there is no duration chip.
|
|
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
|
|
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
|
|
});
|
|
|
|
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
|
|
const { container } = render(
|
|
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
|
|
);
|
|
expect(container.querySelector("img")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("DownloadRow · keyboard", () => {
|
|
it("is a list item only when it is given a position", () => {
|
|
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
|
|
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
|
|
rerender(<DownloadRow job={baseJob} />);
|
|
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
|
|
});
|
|
|
|
it("marks the action that opens the row, and only that one", () => {
|
|
render(
|
|
<DownloadRow job={baseJob} navIndex={0}>
|
|
<IconBtn open onClick={() => {}} title="play">
|
|
p
|
|
</IconBtn>
|
|
<IconBtn onClick={() => {}} title="delete">
|
|
d
|
|
</IconBtn>
|
|
</DownloadRow>,
|
|
);
|
|
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
|
|
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
|
|
});
|
|
});
|