fix(i18n): every refusal the API answers is structured and said in the reader's language

~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
This commit is contained in:
2026-09-19 07:34:17 +02:00
parent dc4e8b9fcd
commit 7ae5c57b6b
39 changed files with 716 additions and 378 deletions
@@ -105,6 +105,31 @@ const goneWhats = [
),
),
];
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
// `setting_refused` picks from at runtime.
const appSources = (dir = ""): string[] =>
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
withFileTypes: true,
}).flatMap((e) =>
e.isDirectory()
? appSources(`${dir}${e.name}/`)
: e.name.endsWith(".py")
? [backend(`${dir}${e.name}`)]
: [],
);
const refusedReasons = [
...new Set([
...appSources().flatMap((src) =>
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
),
...[
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
/:\s*"(\w+)"/g,
),
].map((m) => m[1]!),
]),
];
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
const youtubeReasons = [
...(
@@ -134,6 +159,9 @@ describe("backend ids have their names in both languages", () => {
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
expect(refusedReasons).toEqual(
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
);
// Every code the frontend localizes is raised somewhere the scan can see.
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
});
@@ -200,6 +228,13 @@ describe("backend ids have their names in both languages", () => {
).toEqual([]);
});
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
).toEqual([]);
});
it(`${lang}: every admin refusal reason`, () => {
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
+108 -1
View File
@@ -21,7 +21,21 @@
"job": "This job doesn't exist any more.",
"download": "This download doesn't exist any more.",
"link": "This share link doesn't exist any more.",
"notification": "This notification doesn't exist any more."
"notification": "This notification doesn't exist any more.",
"account": "That account no longer exists.",
"collection": "This collection doesn't exist any more.",
"config_key": "There's no such setting.",
"file": "The file is no longer available.",
"filmstrip": "There's no filmstrip for this.",
"host": "This host isn't on the list any more.",
"image": "There's no image for this.",
"item": "This item doesn't exist any more.",
"library": "This library doesn't exist any more.",
"plex_item": "This Plex item isn't in the library any more.",
"poster": "There's no poster for this.",
"profile": "This download profile doesn't exist any more.",
"season": "This season isn't in the library any more.",
"show": "This show isn't in the library any more."
},
"admin": {
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
@@ -99,5 +113,98 @@
"quota": "Today's YouTube budget for this instance is spent. It resets at midnight Pacific time — try again then.",
"reconnect": "Google no longer accepts this account's YouTube connection. Sign out and sign in with Google again to reconnect it.",
"unavailable": "YouTube didn't accept that just now. Please try again."
},
"refused": {
"account_no_access": "That account no longer has access.",
"account_signed_out": "That account was signed out elsewhere — sign in again.",
"account_suspended": "That account is suspended.",
"address_has_account": "That address already has an account — delete the account instead.",
"admin_first": "Create the admin account first.",
"admin_only": "Only an admin can do that.",
"already_set_up": "This instance is already set up.",
"already_subscribed": "You're already subscribed to this channel.",
"channel_blocked": "You've blocked this channel.",
"collection_fields_required": "A title and an item are both needed.",
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync.",
"collection_read_only": "This collection is read-only.",
"collection_smart": "Smart and auto-list collections can't be made editable.",
"demo_activate": "The demo account can't be activated.",
"demo_delete": "The demo account can't be deleted.",
"demo_role": "The demo account's role can't be changed.",
"demo_suspend": "The demo account can't be suspended.",
"download_not_ready": "This download isn't ready.",
"edit_unfinished": "Only a finished download can be edited.",
"filters_not_object": "The view's filters are malformed.",
"google_no_userinfo": "Google didn't send back the account details. Try signing in again.",
"image_fetch_failed": "The image couldn't be loaded.",
"image_host": "Images from that address aren't loaded.",
"invalid_cursor": "The list position is out of date — reload the page.",
"invalid_email": "Enter a valid email address.",
"invalid_interval": "The interval must be between {{min}} and {{max}} minutes.",
"invalid_link": "That doesn't look like a valid video link.",
"invalid_role": "Unknown role.",
"invalid_status": "Unknown status.",
"invite_decided": "This request has already been decided.",
"job_running": "This job is already running.",
"last_active_admin": "This is the only active admin — make someone else admin first.",
"last_admin": "This is the only admin — make someone else admin first.",
"link_gone": "This link is no longer available.",
"link_required": "A video link or id is required.",
"media_missing": "The media file isn't on disk.",
"message_empty": "The message is empty.",
"message_rate": "Too many messages — slow down a moment.",
"message_self": "You can't message yourself.",
"message_too_long": "The message is too long.",
"messaging_set_up": "Secure messaging is already set up.",
"name_required": "A name is required.",
"no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
"no_messaging": "That member hasn't set up messaging yet.",
"no_profile": "There's no download profile to use.",
"no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first.",
"not_authenticated": "You're not signed in.",
"not_ready": "Not available yet.",
"not_redownloadable": "This item can't be downloaded again.",
"not_your_account": "That's not an account you've signed into here.",
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account.",
"own_delete": "Delete your own account in Settings → Account.",
"own_role": "You can't change your own role.",
"own_suspend": "You can't suspend your own account.",
"plex_disabled": "The Plex module is turned off.",
"plex_not_configured": "Plex isn't set up — add the server address and token in Configuration → Plex.",
"plex_unreachable": "The Plex server didn't answer. Check the address and the token, then try again.",
"plex_write_failed": "Plex didn't accept the change. Try again.",
"profile_name_required": "A profile name is required.",
"quota_reserve": "Today's shared YouTube budget is down to its reserve — try again later.",
"quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.",
"recipient_required": "A recipient email is required.",
"registration_closed": "Registration is currently closed.",
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
"search_term_required": "Enter something to search for.",
"session_gone": "The playback session ended.",
"setting_not_a_choice": "Pick one of: {{choices}}.",
"setting_not_a_number": "Enter a whole number.",
"setting_too_high": "The value can be at most {{max}}.",
"setting_too_low": "The value must be at least {{min}}.",
"setup_token": "The setup token is missing or wrong.",
"share_own": "That's already your download.",
"share_password_required": "This link needs a password.",
"share_unfinished": "Only a finished download can be shared.",
"smtp_failed": "The email couldn't be sent — check the SMTP settings.",
"smtp_off": "SMTP isn't configured.",
"spec_not_object": "The profile's settings are malformed.",
"still_downloading": "This download is still in progress.",
"tag_name_taken": "You already have a tag named “{{name}}”.",
"title_required": "A title is required.",
"too_many_attempts": "Too many attempts. Try again shortly.",
"unknown_setting": "Unknown setting: {{key}}",
"value_missing": "A value is required.",
"video_gone": "This video is no longer available.",
"view_name_taken": "You already have a view named “{{name}}”.",
"watch_sync_off": "Turn on Plex watch-sync first.",
"weak_password": "The password must be at least {{min}} characters.",
"wrong_password": "The current password is incorrect.",
"wrong_share_password": "Wrong password.",
"youtube_editing_off": "Turn on YouTube editing in Settings first.",
"youtube_read_scope": "Connect your YouTube account (read access) first."
}
}
+108 -1
View File
@@ -21,7 +21,21 @@
"job": "Ez a feladat már nem létezik.",
"download": "Ez a letöltés már nem létezik.",
"link": "Ez a megosztási link már nem létezik.",
"notification": "Ez az értesítés már nem létezik."
"notification": "Ez az értesítés már nem létezik.",
"account": "Ez a fiók már nem létezik.",
"collection": "Ez a gyűjtemény már nem létezik.",
"config_key": "Nincs ilyen beállítás.",
"file": "A fájl már nem érhető el.",
"filmstrip": "Ehhez nincs képsor.",
"host": "Ez a kiszolgáló már nincs a listán.",
"image": "Ehhez nincs kép.",
"item": "Ez az elem már nem létezik.",
"library": "Ez a könyvtár már nem létezik.",
"plex_item": "Ez a Plex-elem már nincs a könyvtárban.",
"poster": "Ehhez nincs poszter.",
"profile": "Ez a letöltési profil már nem létezik.",
"season": "Ez az évad már nincs a könyvtárban.",
"show": "Ez a sorozat már nincs a könyvtárban."
},
"admin": {
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
@@ -99,5 +113,98 @@
"quota": "Ennek a példánynak elfogyott a mai YouTube-kerete. Csendes-óceáni idő szerint éjfélkor újraindul — akkor próbáld újra.",
"reconnect": "A Google már nem fogadja el ennek a fióknak a YouTube-kapcsolatát. Jelentkezz ki, majd lépj be újra Google-lel az összekötéshez.",
"unavailable": "A YouTube most nem fogadta el. Próbáld újra."
},
"refused": {
"account_no_access": "Ennek a fióknak már nincs hozzáférése.",
"account_signed_out": "Ezt a fiókot máshol kijelentkeztették — lépj be újra.",
"account_suspended": "Ez a fiók fel van függesztve.",
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette.",
"admin_first": "Előbb hozd létre az admin fiókot.",
"admin_only": "Ezt csak admin teheti meg.",
"already_set_up": "Ez a példány már be van állítva.",
"already_subscribed": "Erre a csatornára már feliratkoztál.",
"channel_blocked": "Ezt a csatornát letiltottad.",
"collection_fields_required": "A címet és egy elemet is meg kell adni.",
"collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja.",
"collection_read_only": "Ez a gyűjtemény csak olvasható.",
"collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé.",
"demo_activate": "A bemutató fiók nem aktiválható.",
"demo_delete": "A bemutató fiók nem törölhető.",
"demo_role": "A bemutató fiók szerepköre nem módosítható.",
"demo_suspend": "A bemutató fiók nem függeszthető fel.",
"download_not_ready": "Ez a letöltés még nincs kész.",
"edit_unfinished": "Csak befejezett letöltés szerkeszthető.",
"filters_not_object": "A nézet szűrői hibás formátumúak.",
"google_no_userinfo": "A Google nem küldte vissza a fiók adatait. Próbálj újra belépni.",
"image_fetch_failed": "A képet nem sikerült betölteni.",
"image_host": "Erről a címről nem töltünk be képet.",
"invalid_cursor": "A lista pozíciója elavult — töltsd újra az oldalt.",
"invalid_email": "Adj meg egy érvényes e-mail címet.",
"invalid_interval": "Az intervallumnak {{min}} és {{max}} perc között kell lennie.",
"invalid_link": "Ez nem tűnik érvényes videólinknek.",
"invalid_role": "Ismeretlen szerepkör.",
"invalid_status": "Ismeretlen állapot.",
"invite_decided": "Erről a kérésről már döntöttek.",
"job_running": "Ez a feladat már fut.",
"last_active_admin": "Ez az egyetlen aktív admin — előbb tegyél meg valaki mást adminnak.",
"last_admin": "Ez az egyetlen admin — előbb tegyél meg valaki mást adminnak.",
"link_gone": "Ez a link már nem érhető el.",
"link_required": "Adj meg egy videólinket vagy -azonosítót.",
"media_missing": "A médiafájl nincs a lemezen.",
"message_empty": "Az üzenet üres.",
"message_rate": "Túl sok üzenet — várj egy kicsit.",
"message_self": "Magadnak nem küldhetsz üzenetet.",
"message_too_long": "Az üzenet túl hosszú.",
"messaging_set_up": "A biztonságos üzenetküldés már be van állítva.",
"name_required": "Adj meg egy nevet.",
"no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t.",
"no_messaging": "Ez a tag még nem állította be az üzenetküldést.",
"no_profile": "Nincs használható letöltési profil.",
"no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat.",
"not_authenticated": "Nem vagy bejelentkezve.",
"not_ready": "Még nem érhető el.",
"not_redownloadable": "Ez az elem nem tölthető le újra.",
"not_your_account": "Ebbe a fiókba itt nem léptél be.",
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak.",
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted.",
"own_role": "A saját szerepkörödet nem módosíthatod.",
"own_suspend": "A saját fiókodat nem függesztheted fel.",
"plex_disabled": "A Plex modul ki van kapcsolva.",
"plex_not_configured": "A Plex nincs beállítva — add meg a szerver címét és a tokent a Konfiguráció → Plex alatt.",
"plex_unreachable": "A Plex-szerver nem válaszolt. Ellenőrizd a címet és a tokent, majd próbáld újra.",
"plex_write_failed": "A Plex nem fogadta el a módosítást. Próbáld újra.",
"profile_name_required": "Adj meg egy profilnevet.",
"quota_reserve": "A mai közös YouTube-keret a tartalékánál tart — próbáld később.",
"quota_spent": "A mai közös YouTube-keret elfogyott. Próbáld holnap.",
"recipient_required": "Adj meg egy címzett e-mail címet.",
"registration_closed": "A regisztráció jelenleg zárva van.",
"search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.",
"search_term_required": "Írj be egy keresőkifejezést.",
"session_gone": "A lejátszási munkamenet véget ért.",
"setting_not_a_choice": "Válassz ezek közül: {{choices}}.",
"setting_not_a_number": "Adj meg egy egész számot.",
"setting_too_high": "Az érték nem lehet nagyobb, mint {{max}}.",
"setting_too_low": "Az érték nem lehet kisebb, mint {{min}}.",
"setup_token": "A beállítási token hiányzik vagy hibás.",
"share_own": "Ez már a te letöltésed.",
"share_password_required": "Ehhez a linkhez jelszó kell.",
"share_unfinished": "Csak befejezett letöltés osztható meg.",
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat.",
"smtp_off": "Az SMTP nincs beállítva.",
"spec_not_object": "A profil beállításai hibás formátumúak.",
"still_downloading": "Ez a letöltés még folyamatban van.",
"tag_name_taken": "Már van „{{name}}” nevű címkéd.",
"title_required": "Adj meg egy címet.",
"too_many_attempts": "Túl sok próbálkozás. Próbáld kicsit később.",
"unknown_setting": "Ismeretlen beállítás: {{key}}",
"value_missing": "Adj meg egy értéket.",
"video_gone": "Ez a videó már nem érhető el.",
"view_name_taken": "Már van „{{name}}” nevű nézeted.",
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront.",
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia.",
"wrong_password": "A jelenlegi jelszó helytelen.",
"wrong_share_password": "Hibás jelszó.",
"youtube_editing_off": "Előbb kapcsold be a YouTube-szerkesztést a Beállításokban.",
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés)."
}
}