fix(clip): a clip is held to its owner's storage cap, and its limits and failures say the server's truth

- The cut file is measured against what its owner has left, for a YouTube range (whose fetch never
  reads yt-dlp's size ceiling) and for a Plex clip (which fetches nothing), before the fetch and
  again before it is placed; a refusal leaves the asset pending for a Retry.
- A silent Plex file is cut silent; a file the probe cannot read says so.
- A second job on a failed or renamed clip gets the same YouTube diagnosis and the fetched title.
- The "too long" refusal names the server's cap; a contract test holds the client's copies of the
  clip limits to clip.py; the shipped player clients are pinned against the installed yt-dlp.
- One HDR transfer set and one rating_key lookup; job status words from JobStatus; the display-name
  guard sees subscript writes and the clip route; stale comments and types follow the clip job.
This commit is contained in:
2026-10-10 15:35:12 +02:00
parent 72df11f2c0
commit b4bf71ea64
25 changed files with 385 additions and 89 deletions
@@ -170,10 +170,11 @@ const youtubeReasons = [
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>")` in the backend.
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>"…)` in the backend
// (a reason may carry its numbers after it: `ClipError("too_long", limit_s=…)`).
const clipReasons = [
...new Set(
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"\)/g)].map((m) => m[1]!)),
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"[,)]/g)].map((m) => m[1]!)),
),
];
+1 -1
View File
@@ -26,7 +26,7 @@
"bad_source": "This video can’t be clipped.",
"bad_range": "The clip’s start or end isn’t valid.",
"too_short": "A clip is at least a second long.",
"too_long": "A clip is at most 10 minutes long.",
"too_long": "A clip is at most {{minutes}} minutes long.",
"bad_audio": "That audio track isn’t available.",
"bad_crop": "The crop frame isn’t valid. Make it bigger, or go back to the whole picture.",
"not_found": "This item is no longer in the library.",
+1 -1
View File
@@ -26,7 +26,7 @@
"bad_source": "Ebből a videóból nem lehet klipet vágni.",
"bad_range": "A klip eleje vagy vége érvénytelen.",
"too_short": "A klip legalább egy másodperc.",
"too_long": "A klip legfeljebb 10 perc lehet.",
"too_long": "A klip legfeljebb {{minutes}} perc lehet.",
"bad_audio": "Ez a hangsáv nem érhető el.",
"bad_crop": "A kivágókeret érvénytelen. Nagyítsd meg, vagy térj vissza a teljes képre.",
"not_found": "Ez az elem már nincs a könyvtárban.",
+18
View File
@@ -261,6 +261,24 @@ describe("req — a structured refusal reads in the user's language", () => {
).rejects.toMatchObject({ detail: localized });
});
it("states the clip cap the server refused with, not a copy of it", async () => {
// The cap is the server's (downloads/clip.py MAX_CLIP_S): a message with "10 minutes" baked in
// goes stale the day it moves.
const i18n = (await import("../../i18n")).default;
const t = vi.spyOn(i18n, "t");
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "clip", reason: "too_long", limit_s: 300 } }), {
status: 422,
headers: { "content-type": "application/json" },
}),
);
await req("/api/downloads/clip", { method: "POST" }, { quiet: true }).catch(() => undefined);
expect(t).toHaveBeenCalledWith(
"clip.errors.too_long",
expect.objectContaining({ minutes: "5" }),
);
});
it.each(["quota", "reconnect", "unavailable"])(
"says what to do about an action YouTube refused (%s)",
async (reason) => {
+7 -3
View File
@@ -81,7 +81,7 @@ function localizeDetail(d: unknown): string {
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; min?: number }
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
@@ -95,8 +95,12 @@ function localizeDetail(d: unknown): string {
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key.
if (o?.code === "clip") return t(`clip.errors.${o.reason}`, t("clip.failed"));
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
+5 -3
View File
@@ -49,8 +49,7 @@ export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error"
// from the source). Only a download has a re-fetchable source; re-download refuses the rest.
export type DownloadJobKind = "download" | "edit" | "upload" | "clip";
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
// An ad-hoc clip (backend downloads/clip): a marked range of a YouTube video or a Plex item.
export interface ClipRequest {
source: "youtube" | "plex";
ref: string;
@@ -62,6 +61,8 @@ export interface ClipRequest {
display_name?: string;
}
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
export interface EditSpec {
trim?: { start_s: number; end_s?: number };
segments?: { start_s: number; end_s: number }[];
@@ -102,7 +103,8 @@ export interface DownloadJob {
extra_links: string[]; // extra reference URLs (beyond source_url)
job_kind?: DownloadJobKind; // see DownloadJobKind; absent reads as "download"
source_job_id?: number | null; // parent download an edit was cut from
edit_spec?: EditSpec | null;
// An edit's recipe, or a clip's spec (its request without the name) — by job_kind.
edit_spec?: EditSpec | Omit<ClipRequest, "display_name"> | null;
can_download: boolean;
expired: boolean;
asset: DownloadAsset | null;
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The limits a clip is marked to, held in two languages: the clip panel's handles, fields and keys
// keep a range the server takes, and a crop frame no smaller than it cuts. Neither side can see the
// other's copy, so the gate reads both (as `api/downloads.contract.test.ts` does for the name bound).
// A cap moved on one side alone makes the panel offer what the server refuses — or clamp below
// what it would take — with every other test green.
//
// Read as TEXT: the modules are plain constants, but reading the source is what makes a renamed or
// moved constant fail here instead of comparing two undefineds.
const src = (u: string) => readFileSync(new URL(u, import.meta.url), "utf-8");
const backend = src("../../../backend/app/downloads/clip.py");
function pyConst(name: string): number {
const m = backend.match(new RegExp(`^${name}\\s*=\\s*([\\d.]+)`, "m"));
expect(m, `backend/app/downloads/clip.py no longer defines ${name}`).toBeTruthy();
return Number(m![1]);
}
function tsConst(file: string, name: string): number {
const m = src(file).match(new RegExp(`^(?:export )?const ${name} = ([\\d.]+);`, "m"));
expect(m, `${file} no longer defines ${name}`).toBeTruthy();
return Number(m![1]);
}
describe("clip limits", () => {
it.each([
["./clipRange.ts", "MIN_CLIP_S"],
["./clipRange.ts", "MAX_CLIP_S"],
["./cropGeometry.ts", "MIN_CROP"],
])("%s %s is the server's", (file, name) => {
expect(tsConst(file, name)).toBe(pyConst(name));
});
});
+12 -1
View File
@@ -1,6 +1,7 @@
// The range an ad-hoc clip is cut from, marked in a player: the rules the clip panel's handles, its
// In / Out fields and the players' `[` / `]` keys all go by, so every way of marking keeps a range the
// server takes (backend downloads/clip.py: at least MIN_CLIP_S, at most MAX_CLIP_S — the owner's cap).
// server takes (backend downloads/clip.py: at least MIN_CLIP_S, at most MAX_CLIP_S — the owner's cap;
// clipLimits.contract.test.ts holds the two copies equal).
const MIN_CLIP_S = 1;
export const MAX_CLIP_S = 600;
@@ -53,3 +54,13 @@ export function zoomWindow(r: ClipRange, duration: number): { from: number; to:
const pad = Math.max(5, (r.end - r.start) * 0.25);
return { from: Math.max(0, r.start - pad), to: Math.min(duration, r.end + pad) };
}
/** The `[` key: In at the playhead `t`, opening a range there when none is marked yet. */
export function markInAt(r: ClipRange | null, t: number, duration: number): ClipRange {
return markIn(r ?? openAt(t, duration), t, duration);
}
/** The `]` key: Out at the playhead `t`; with no range yet, one that ENDS there. */
export function markOutAt(r: ClipRange | null, t: number, duration: number): ClipRange {
return markOut(r ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), duration), t, duration);
}
+2 -1
View File
@@ -53,7 +53,8 @@ export function cropToSourcePx(crop: Frac, srcW: number, srcH: number): CropPx |
// picture is shown at. A shape lock is the frame's width / height IN THOSE FRACTIONS: a 9:16 output
// on a 16:9 picture is (9/16) / (16/9) — `fracRatio` turns one into the other.
/** The smallest a frame gets on either side (the server holds the same floor, downloads/clip). */
/** The smallest a frame gets on either side: the server's floor (downloads/clip), held equal by
* clipLimits.contract.test.ts. */
export const MIN_CROP = 0.1;
export const WHOLE: Frac = { x: 0, y: 0, w: 1, h: 1 };