feat(i18n): the server's closed English vocabularies named in the reader's language (E30 S2)

- lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data
  stays English, the catalog is named at its one source, useTags), built-in download profiles,
  YouTube topics; held key-for-key to the backend's own lists by backendIds.contract.
- Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's
  own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…)
  from the catalog.
- Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's
  server-language displayTitle; no invented "Audio N" on the server.
- Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share
  notice carries the title.
- Scheduler: the job result rides along as data and is named per key (dashboard + run notice).
- Invite status named; a render crash's notice no longer shows the engine's English message.
- Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic.
This commit is contained in:
2026-09-29 21:21:19 +02:00
parent f6298d29af
commit f3a85916ec
50 changed files with 1475 additions and 94 deletions
+49 -3
View File
@@ -12,6 +12,7 @@ the row. The enrich batch + scheduler entry live here too so the whole TMDB inte
import time
from datetime import datetime, timezone
from typing import cast
import httpx
from sqlalchemy import func, select
@@ -125,6 +126,11 @@ class TmdbClient:
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
return self._get(f"/find/{external_id}", {"external_source": source})
def countries(self) -> list[dict] | None:
"""TMDB's country list: `[{iso_3166_1, english_name, native_name}]` (a JSON array, which
`_get`'s dict annotation does not describe)."""
return cast("list[dict] | None", self._get("/configuration/countries"))
def fetch_person(self, person_id: int) -> dict | None:
"""One person's bio + combined credits (E5 S6), in a single append_to_response request."""
return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"})
@@ -255,8 +261,12 @@ def parse_tmdb(payload: dict, media_type: str) -> dict:
c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")
][:8]
or None,
# The ISO code beside TMDB's English name (E30): the client names the country in the reader's
# language from the code; the name is the fallback for a code it cannot name.
"production_countries": [
c.get("name") for c in (payload.get("production_countries") or []) if c.get("name")
{"code": c.get("iso_3166_1") or None, "name": c["name"]}
for c in (payload.get("production_countries") or [])
if c.get("name")
]
or None,
"recommendations": recs or None,
@@ -357,13 +367,49 @@ def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int:
return processed
def _country(c: dict | str) -> dict:
"""One stored production country as served: `{code, name}`. A row enriched before E30 stored the
bare English name; until `backfill_country_codes` reaches it, it serves with no code (the client
shows the name)."""
return c if isinstance(c, dict) else {"code": None, "name": c}
def backfill_country_codes(db: Session, tmdb: TmdbClient) -> int:
"""Give the countries stored before E30 (bare English names) their ISO codes, from TMDB's own
country list — the names came from that list, so they match it exactly. One request, and only
while such a row is left: re-fetching each title instead would take the enrichment batch days.
A name the list does not hold keeps no code. Returns the number of rows rewritten."""
stale = [
row
for model in (PlexItem, PlexShow)
for row in db.execute(
select(model).where(func.jsonb_typeof(model.production_countries[0]) == "string")
).scalars()
]
if not stale:
return 0
codes = {
c["english_name"]: c["iso_3166_1"]
for c in tmdb.countries() or []
if c.get("english_name") and c.get("iso_3166_1")
}
for row in stale:
row.production_countries = [
{"code": codes.get(c), "name": c} if isinstance(c, str) else c
for c in row.production_countries or []
]
db.commit()
return len(stale)
def run_enrich(db: Session) -> dict:
"""Scheduler entry point. No-op (never an error) when the TMDB key isn't configured."""
if not sysconfig.get_str(db, "tmdb_api_key"):
return {"enriched": 0, "skipped": "tmdb_api_key not set"}
with TmdbClient(db) as tmdb:
coded = backfill_country_codes(db, tmdb)
n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch)
return {"enriched": n}
return {"enriched": n, **({"countries_coded": coded} if coded else {})}
def resolve_library_guids(
@@ -445,7 +491,7 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None:
"keywords": row.keywords or [],
"crew": row.crew or [],
"studios": row.studios or [],
"production_countries": row.production_countries or [],
"production_countries": [_country(c) for c in row.production_countries or []],
"recommendations": resolve_recs(db, row.recommendations, lang=lang),
"backdrops": row.backdrops or [],
}