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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user