fix(clip): a clip refusal speaks the user's language, and a choice in the clip form no longer sends it
The backend refuses a clip with a structured reason, but the client showed every one as the generic "couldn't make the clip": each reason now has its own message in both languages, held to the backend's reasons in both directions by the contract test. A segmented toggle's and the priority stepper's buttons had no type, so inside a form one of them submitted it.
This commit is contained in:
@@ -36,6 +36,7 @@ function PriorityStepButton({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
|
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +163,7 @@ export function PriorityStepper({
|
|||||||
} ${cls.value}`}
|
} ${cls.value}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
ref={editTarget}
|
ref={editTarget}
|
||||||
onClick={beginEdit}
|
onClick={beginEdit}
|
||||||
aria-label={t("common.priority.edit")}
|
aria-label={t("common.priority.edit")}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export default function SegmentedToggle<T extends string>({
|
|||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o.value}
|
key={o.value}
|
||||||
|
// A choice, never a submit: inside a form (the clip panel) a bare button would send it.
|
||||||
|
type="button"
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
title={o.title}
|
title={o.title}
|
||||||
aria-pressed={value === o.value}
|
aria-pressed={value === o.value}
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ const youtubeReasons = [
|
|||||||
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
||||||
).matchAll(/"(\w+)"/g),
|
).matchAll(/"(\w+)"/g),
|
||||||
].map((m) => m[1]!);
|
].map((m) => m[1]!);
|
||||||
|
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>")` in the backend.
|
||||||
|
const clipReasons = [
|
||||||
|
...new Set(
|
||||||
|
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"\)/g)].map((m) => m[1]!)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
|
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
|
||||||
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
|
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
|
||||||
@@ -233,6 +239,7 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||||
expect(opaqueWhats).toEqual([]);
|
expect(opaqueWhats).toEqual([]);
|
||||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
||||||
|
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end"]));
|
||||||
expect(crewJobs).toEqual(
|
expect(crewJobs).toEqual(
|
||||||
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
||||||
);
|
);
|
||||||
@@ -336,6 +343,13 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`${lang}: every reason a clip is refused, and none it cannot be`, () => {
|
||||||
|
expect(missing(clipReasons.map((r) => `clip.errors.${r}`))).toEqual([]);
|
||||||
|
expect([...idsUnder(keys, "clip.errors")].filter((r) => !clipReasons.includes(r))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
|
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
|
||||||
for (const [group, words] of Object.entries(VOCAB)) {
|
for (const [group, words] of Object.entries(VOCAB)) {
|
||||||
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
||||||
|
|||||||
@@ -17,5 +17,15 @@
|
|||||||
"cut": "Cut clip",
|
"cut": "Cut clip",
|
||||||
"queued": "The clip is being made — it will be in your downloads",
|
"queued": "The clip is being made — it will be in your downloads",
|
||||||
"view": "Downloads",
|
"view": "Downloads",
|
||||||
"failed": "Couldn’t make the clip"
|
"failed": "Couldn’t make the clip",
|
||||||
|
"errors": {
|
||||||
|
"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.",
|
||||||
|
"bad_audio": "That audio track isn’t available.",
|
||||||
|
"not_found": "This item is no longer in the library.",
|
||||||
|
"media_missing": "The item’s file can’t be reached from here.",
|
||||||
|
"past_end": "The clip runs past the end of the video."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,15 @@
|
|||||||
"cut": "Klip vágása",
|
"cut": "Klip vágása",
|
||||||
"queued": "Készül a klip — a letöltéseid közé kerül",
|
"queued": "Készül a klip — a letöltéseid közé kerül",
|
||||||
"view": "Letöltések",
|
"view": "Letöltések",
|
||||||
"failed": "Nem sikerült elkészíteni a klipet"
|
"failed": "Nem sikerült elkészíteni a klipet",
|
||||||
|
"errors": {
|
||||||
|
"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.",
|
||||||
|
"bad_audio": "Ez a hangsáv nem érhető el.",
|
||||||
|
"not_found": "Ez az elem már nincs a könyvtárban.",
|
||||||
|
"media_missing": "Az elem fájlja innen nem érhető el.",
|
||||||
|
"past_end": "A klip túlnyúlik a videó végén."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,22 @@ describe("req — a structured refusal reads in the user's language", () => {
|
|||||||
expect(err.detail).toBeUndefined();
|
expect(err.detail).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["too_long", "clip.errors.too_long"],
|
||||||
|
["bad_crop", "clip.errors.bad_crop"],
|
||||||
|
])("says why a clip was not cut (%s)", async (reason, localized) => {
|
||||||
|
// It read as the generic "couldn't carry out that action" — a 10-minute cap no retry gets past.
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ detail: { code: "clip", reason } }), {
|
||||||
|
status: 422,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
req("/api/downloads/clip", { method: "POST" }, { quiet: true }),
|
||||||
|
).rejects.toMatchObject({ detail: localized });
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["quota", "reconnect", "unavailable"])(
|
it.each(["quota", "reconnect", "unavailable"])(
|
||||||
"says what to do about an action YouTube refused (%s)",
|
"says what to do about an action YouTube refused (%s)",
|
||||||
async (reason) => {
|
async (reason) => {
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ function localizeDetail(d: unknown): string {
|
|||||||
}
|
}
|
||||||
if (o?.code === "edit")
|
if (o?.code === "edit")
|
||||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
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"));
|
||||||
if (o?.code === "cookies")
|
if (o?.code === "cookies")
|
||||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
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
|
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
|
||||||
|
|||||||
Reference in New Issue
Block a user