feat(i18n): every server refusal is said by a record the contract holds

A refusal code or reason with no sentence fell through to the generic
"couldn't carry out that action" unnoticed: the parity test is green when
EN and HU miss the same key. One did (an edit refused for a file that is
not video or audio).

- lib/api/refusalCodes.ts: which sentence answers each code, as data;
  localizeDetail says a refusal through it, unchanged for every reader.
- backendIds contract: every detail code the backend sends is in exactly
  one record, every reason it can carry has its sentence in EN and HU,
  and no family names a reason the backend cannot send.
- The reasons that travel in a variable are Literal types (edit, move,
  cookies, quota), so the type checker refuses an unknown one too.
- downloads.errors.edit.not_media in EN and HU.
This commit is contained in:
2026-10-30 23:36:25 +01:00
parent b3be568e36
commit 4379f76747
14 changed files with 381 additions and 123 deletions
+10
View File
@@ -25,6 +25,7 @@ Security controls enforced here:
import os
from pathlib import Path
from typing import Literal
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -163,9 +164,18 @@ def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredenti
).scalar_one_or_none()
# Every reason a pasted cookies.txt is refused; the UI names each one (`downloads.errors.cookies.<reason>`,
# held by the frontend's backendIds contract, which reads this Literal).
CookieReason = Literal["empty", "format"]
class InvalidCookies(ValueError):
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
def __init__(self, reason: CookieReason):
super().__init__(reason)
self.reason = reason
def validate_cookies(raw: str) -> str:
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
+9 -3
View File
@@ -20,6 +20,7 @@ their output's real size before it is placed. Edit jobs do not consult it at all
"""
from dataclasses import dataclass
from typing import Literal
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
@@ -50,10 +51,15 @@ class QuotaLimits:
unlimited: bool
class QuotaExceeded(Exception):
"""Raised by check_enqueue; `reason` is one of max_jobs|max_bytes (i18n key on the client)."""
# Every cap a download can hit; the UI names each one (`downloads.errors.quota.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
QuotaReason = Literal["max_jobs", "max_bytes"]
def __init__(self, reason: str, limit: int, current: int):
class QuotaExceeded(Exception):
"""Raised by check_enqueue; `reason` names the cap it hit (an i18n key on the client)."""
def __init__(self, reason: QuotaReason, limit: int, current: int):
self.reason = reason
self.limit = limit
self.current = current
+9 -3
View File
@@ -10,6 +10,7 @@ handler); the folder, link and quota writes are `folders`, `links` and `quota`.
import uuid
from datetime import datetime, timezone
from typing import Literal
from sqlalchemy import delete as delete_rows
from sqlalchemy import func, select, update
@@ -39,10 +40,15 @@ from app.plex import paths as plex_paths
from app.userscope import owned_by, owned_row
class EditError(Exception):
"""Bad edit request (empty spec / source not ready); `reason` is an i18n-mappable key."""
# Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
# the frontend's backendIds contract, which reads this Literal).
EditReason = Literal["source_not_ready", "not_media", "empty_edit"]
def __init__(self, reason: str):
class EditError(Exception):
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
def __init__(self, reason: EditReason):
self.reason = reason
super().__init__(reason)
+7 -2
View File
@@ -9,7 +9,7 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
from datetime import datetime, timezone
from typing import cast
from typing import Literal, cast
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -301,10 +301,15 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
# --- playlists -------------------------------------------------------------------------------
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
MoveReason = Literal["same_playlist", "not_in_playlist"]
class MoveRefused(Exception):
"""A move between playlists that cannot happen: `reason` is the code the UI translates."""
def __init__(self, reason: str):
def __init__(self, reason: MoveReason):
super().__init__(reason)
self.reason = reason
+4 -2
View File
@@ -8,8 +8,10 @@ whatever sentence the route happened to hand-write. `refused` is the generic fam
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
languages — so the rule is enforced, not described.
and the frontend's contract test (`i18n/backendIds.contract.test.ts`) requires every `code` a detail
carries to be in one record of `lib/api/refusalCodes.ts`, and every reason it can be sent with — a
literal, or a `Literal` type when it travels in a variable — to have its sentence in both languages.
So the rule is enforced, not described.
"""
from fastapi import HTTPException
+1 -1
View File
@@ -208,7 +208,7 @@ def set_credential(
try:
cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e:
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": e.reason}) from e
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
audit.record(
db,