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 import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import cast
import httpx import httpx
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -125,6 +126,11 @@ class TmdbClient:
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find.""" """Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
return self._get(f"/find/{external_id}", {"external_source": source}) 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: def fetch_person(self, person_id: int) -> dict | None:
"""One person's bio + combined credits (E5 S6), in a single append_to_response request.""" """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"}) 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") c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")
][:8] ][:8]
or None, 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": [ "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, or None,
"recommendations": recs or None, "recommendations": recs or None,
@@ -357,13 +367,49 @@ def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int:
return processed 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: def run_enrich(db: Session) -> dict:
"""Scheduler entry point. No-op (never an error) when the TMDB key isn't configured.""" """Scheduler entry point. No-op (never an error) when the TMDB key isn't configured."""
if not sysconfig.get_str(db, "tmdb_api_key"): if not sysconfig.get_str(db, "tmdb_api_key"):
return {"enriched": 0, "skipped": "tmdb_api_key not set"} return {"enriched": 0, "skipped": "tmdb_api_key not set"}
with TmdbClient(db) as tmdb: with TmdbClient(db) as tmdb:
coded = backfill_country_codes(db, tmdb)
n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch) 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( def resolve_library_guids(
@@ -445,7 +491,7 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None:
"keywords": row.keywords or [], "keywords": row.keywords or [],
"crew": row.crew or [], "crew": row.crew or [],
"studios": row.studios 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), "recommendations": resolve_recs(db, row.recommendations, lang=lang),
"backdrops": row.backdrops or [], "backdrops": row.backdrops or [],
} }
+8 -1
View File
@@ -917,7 +917,14 @@ def share_download(
user_id=recipient.id, user_id=recipient.id,
type="download_shared", type="download_shared",
title=(job.display_name or "A download") + " was shared with you", title=(job.display_name or "A download") + " was shared with you",
data={"kind": "download_shared", "from": user.email, "job_id": job_id}, # The client names the notice in the reader's language from `data` (E30); the title is the
# English fallback only.
data={
"kind": "download_shared",
"from": user.email,
"job_id": job_id,
"title": job.display_name,
},
commit=False, commit=False,
) )
db.commit() db.commit()
+23 -6
View File
@@ -1,5 +1,6 @@
"""Plex show/item detail: the show drill-down + the per-item player payload (markers, cast, nav).""" """Plex show/item detail: the show drill-down + the per-item player payload (markers, cast, nav)."""
import re
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -342,6 +343,26 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None) return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
# The technical tail of Plex's track title — "English (AC3 5.1)" → "AC3 5.1".
_TRACK_TAIL = re.compile(r"\(([^()]+)\)\s*$")
def _track_naming(s: dict) -> dict:
"""What the player names a track from (E30). Plex's `displayTitle` is in the SERVER's language
("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so it is sent only as the fallback
`label`; the client builds the name in the reader's language from the language tag, the
forced / SDH flags and the codec tail. No title and no language → no label (the client numbers it)."""
title = s.get("displayTitle") or s.get("language") or None
tail = _TRACK_TAIL.search(title) if title else None
return {
"label": title,
"lang_tag": s.get("languageTag") or None,
"forced": bool(s.get("forced")),
"hearing_impaired": bool(s.get("hearingImpaired")),
"detail": tail.group(1).strip() if tail else None,
}
@router.get("/item/{rating_key}") @router.get("/item/{rating_key}")
def item_detail( def item_detail(
rating_key: str, rating_key: str,
@@ -387,9 +408,7 @@ def item_detail(
audio_streams.append( audio_streams.append(
{ {
"ord": len(audio_streams), "ord": len(audio_streams),
"label": s.get("displayTitle") **_track_naming(s),
or s.get("language")
or f"Audio {len(audio_streams) + 1}",
"language": s.get("languageTag") or s.get("language"), "language": s.get("languageTag") or s.get("language"),
"default": bool(s.get("selected") or s.get("default")), "default": bool(s.get("selected") or s.get("default")),
} }
@@ -399,9 +418,7 @@ def item_detail(
subtitle_streams.append( subtitle_streams.append(
{ {
"ord": len(subtitle_streams), "ord": len(subtitle_streams),
"label": s.get("displayTitle") **_track_naming(s),
or s.get("language")
or f"Subtitle {len(subtitle_streams) + 1}",
"language": s.get("languageTag") or s.get("language"), "language": s.get("languageTag") or s.get("language"),
"codec": codec, "codec": codec,
# Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub) # Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub)
+40 -6
View File
@@ -115,7 +115,9 @@ def _record(name: str, **fields) -> None:
_activity.setdefault(name, {}).update(fields) _activity.setdefault(name, {}).update(fields)
def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | None) -> None: def _notify_done(
db, actor_id: int, job_id: str, status: str, summary: str | None, result: dict | None = None
) -> None:
"""Post a completion notice to the triggering admin's inbox (manual runs only). Best """Post a completion notice to the triggering admin's inbox (manual runs only). Best
effort — a notification failure must never mask the job's own outcome.""" effort — a notification failure must never mask the job's own outcome."""
try: try:
@@ -125,7 +127,7 @@ def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | Non
type="scheduler", type="scheduler",
title=f"{job_id}: {status}", # English fallback; UI renders translated from data title=f"{job_id}: {status}", # English fallback; UI renders translated from data
body=summary, body=summary,
data={"job_id": job_id, "status": status, "summary": summary}, data={"job_id": job_id, "status": status, "summary": summary, "result": result},
) )
except Exception: except Exception:
db.rollback() db.rollback()
@@ -192,25 +194,41 @@ def _job(name: str, fn) -> None:
status="skipped", status="skipped",
last_finished=_now_iso(), last_finished=_now_iso(),
last_result="paused", last_result="paused",
result=_PAUSED,
progress=None, progress=None,
) )
if actor_id is not None: if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused") _notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED)
_audit_run(db, actor_id, name, "skipped", "sync paused", None) _audit_run(db, actor_id, name, "skipped", "sync paused", None)
return return
with progress.bind(sink): with progress.bind(sink):
result = fn(db) result = fn(db)
summary = _summarize(result) summary = _summarize(result)
data = _result_data(result)
log.info("job %s -> %s", name, result) log.info("job %s -> %s", name, result)
_record(name, status="ok", last_finished=_now_iso(), last_result=summary, progress=None) _record(
name,
status="ok",
last_finished=_now_iso(),
last_result=summary,
result=data,
progress=None,
)
if actor_id is not None: if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary) _notify_done(db, actor_id, name, "ok", summary, data)
_audit_run(db, actor_id, name, "ok", summary, result) _audit_run(db, actor_id, name, "ok", summary, result)
except Exception as exc: except Exception as exc:
db.rollback() db.rollback()
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name)) log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
err = str(exc) or exc.__class__.__name__ err = str(exc) or exc.__class__.__name__
_record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None) _record(
name,
status="error",
last_finished=_now_iso(),
last_error=err,
result=None,
progress=None,
)
if actor_id is not None: if actor_id is not None:
_notify_done(db, actor_id, name, "error", err) _notify_done(db, actor_id, name, "error", err)
_audit_run(db, actor_id, name, "error", err, None) _audit_run(db, actor_id, name, "error", err, None)
@@ -221,6 +239,21 @@ def _job(name: str, fn) -> None:
db.close() db.close()
# A skipped run's result, in the same shape as a job's own `{"skipped": …}`.
_PAUSED = {"skipped": "sync paused"}
def _result_data(result) -> dict | None:
"""A job's result as the client names it in the reader's language (E30): the dict itself (counts,
a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line
`_summarize` is the English fallback — and what the audit log keeps."""
if isinstance(result, dict):
return result or None
if isinstance(result, int) and not isinstance(result, bool):
return {"enriched": result}
return None
def _summarize(result) -> str | None: def _summarize(result) -> str | None:
"""Compact one-line rendering of a job's return value for the dashboard.""" """Compact one-line rendering of a job's return value for the dashboard."""
if result is None: if result is None:
@@ -259,6 +292,7 @@ def scheduler_snapshot() -> dict:
"last_started": a.get("last_started"), "last_started": a.get("last_started"),
"last_finished": a.get("last_finished"), "last_finished": a.get("last_finished"),
"last_result": a.get("last_result"), "last_result": a.get("last_result"),
"result": a.get("result"),
"last_error": a.get("last_error"), "last_error": a.get("last_error"),
"progress": a.get("progress"), "progress": a.get("progress"),
} }
+3 -1
View File
@@ -115,7 +115,9 @@ _TOPIC_SLUGS = {
"Tourism": "Travel", "Tourism": "Travel",
"Fashion": "Fashion & Beauty", "Fashion": "Fashion & Beauty",
"Physical_attractiveness": "Fashion & Beauty", "Physical_attractiveness": "Fashion & Beauty",
"Humor": "Comedy", # YouTube links the British page (…/wiki/Humour); the key was "Humor", so no channel ever got Comedy
# from its topic (E30).
"Humour": "Comedy",
"Entertainment": "Entertainment", "Entertainment": "Entertainment",
"Film": "Film", "Film": "Film",
"Television_program": "TV", "Television_program": "TV",
+24
View File
@@ -0,0 +1,24 @@
"""A YouTube topic URL's slug → the auto-tag it gives (pure). The slugs are the ones YouTube actually
links (measured on the owner's channels, 2026-09-29): a key spelled differently from YouTube's page
never matches, silently — "Humor" sat in the map for months while YouTube links …/wiki/Humour."""
import pytest
from app.sync.autotag import map_topic_slug
@pytest.mark.parametrize(
("slug", "tag"),
[
("Humour", "Comedy"),
("Video_game_culture", "Gaming"),
("Electronic_music", "Music"),
("Association_football", "Sports"),
("Knowledge", "Education"),
("Physical_fitness", "Health & Fitness"),
("Television_program", "TV"),
("Lifestyle_(sociology)", None), # YouTube's catch-all: deliberately no tag
],
)
def test_a_youtube_topic_slug_gives_its_tag(slug, tag):
assert map_topic_slug(slug) == tag
@@ -0,0 +1,41 @@
"""E30: the "shared with you" notice carries the download's title in its data, so the client can name
the notice in the reader's language. Its stored title is an English sentence ("Cats was shared with
you") — the only place the title lived, so a Hungarian reader got the English. Needs the DB."""
from sqlalchemy import select
from app.models import DownloadJob, MediaAsset, Notification
def test_a_share_notice_carries_the_title_and_the_sharer(db, make_user, as_user):
owner = make_user("owner@example.com")
friend = make_user("friend@example.com")
asset = MediaAsset(
source_kind="url", source_ref="share-e30", format_sig="sig", status="ready", ref_count=1
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=owner.id,
asset_id=asset.id,
source_kind="url",
source_ref="share-e30",
status="done",
queue_pos=1,
profile_snapshot={},
display_name="Cats",
)
db.add(job)
db.commit()
r = as_user(owner).post(f"/api/downloads/{job.id}/share", json={"email": "friend@example.com"})
assert r.status_code == 200, r.text
notice = db.execute(select(Notification).where(Notification.user_id == friend.id)).scalar_one()
assert notice.type == "download_shared"
assert notice.data == {
"kind": "download_shared",
"from": "owner@example.com",
"job_id": job.id,
"title": "Cats",
}
+49
View File
@@ -352,3 +352,52 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
assert show.imdb_id == "tt0068646" # persisted for next time assert show.imdb_id == "tt0068646" # persisted for next time
assert show.people_enriched_at is not None assert show.people_enriched_at is not None
assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"] assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"]
def test_item_detail_names_tracks_from_their_language_not_plexs_title(
db, monkeypatch, override_dep
):
"""E30: Plex titles a track in the Plex SERVER's language ("English (AC3 5.1)", "magyar Forced",
"中文 (EAC3 Stereo)"), so a Hungarian reader got a mix. The route sends what the client names it
from instead — the language tag, the forced / SDH flags, the codec tail — keeping Plex's title
only as the fallback, and no invented English "Audio 2" for a track with neither."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="T"))
db.commit()
user = _user(db, "tracks@example.com")
db.commit()
streams = [
{"streamType": 2, "displayTitle": "English (AC3 5.1)", "languageTag": "en-US"},
{"streamType": 2, "displayTitle": "中文 (EAC3 Stereo)", "languageTag": "zh"},
{"streamType": 2},
{
"streamType": 3,
"displayTitle": "magyar Forced",
"languageTag": "hu",
"forced": True,
"codec": "srt",
},
{
"streamType": 3,
"displayTitle": "English SDH",
"languageTag": "en",
"hearingImpaired": True,
"codec": "srt",
},
]
meta = {**RICH_META, "Media": [{"Part": [{"Stream": streams}]}]}
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
body = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1").json()
naming = ("label", "lang_tag", "forced", "hearing_impaired", "detail")
assert [tuple(a[k] for k in naming) for a in body["audio_streams"]] == [
("English (AC3 5.1)", "en-US", False, False, "AC3 5.1"),
("中文 (EAC3 Stereo)", "zh", False, False, "EAC3 Stereo"),
(None, None, False, False, None),
]
assert [tuple(s[k] for k in naming) for s in body["subtitle_streams"]] == [
("magyar Forced", "hu", True, False, None),
("English SDH", "en", False, True, None),
]
+69 -2
View File
@@ -49,7 +49,7 @@ MOVIE_PAYLOAD = {
], ],
}, },
"production_companies": [{"name": "Paramount"}, {"name": "Alfran"}], "production_companies": [{"name": "Paramount"}, {"name": "Alfran"}],
"production_countries": [{"name": "United States of America"}], "production_countries": [{"iso_3166_1": "US", "name": "United States of America"}],
"recommendations": { "recommendations": {
"results": [ "results": [
{ {
@@ -126,7 +126,8 @@ def test_parse_movie_pulls_all_fields():
}, },
] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job) ] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job)
assert d["studios"] == ["Paramount", "Alfran"] assert d["studios"] == ["Paramount", "Alfran"]
assert d["production_countries"] == ["United States of America"] # E30: the code beside the English name, so the client can name the country in the reader's language.
assert d["production_countries"] == [{"code": "US", "name": "United States of America"}]
assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999] assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999]
assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"] assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"]
# cast + crew names → tmdb person ids for the plex_people foundation # cast + crew names → tmdb person ids for the plex_people foundation
@@ -429,3 +430,69 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db):
} }
assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900" assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900"
assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500" assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500"
# ---- E30: countries by ISO code -----------------------------------------------------------------
class FakeCountries(FakeTmdb):
"""TMDB's /configuration/countries, counting the requests."""
def __init__(self):
super().__init__()
self.calls = 0
def countries(self):
self.calls += 1
return [
{"iso_3166_1": "US", "english_name": "United States of America"},
{"iso_3166_1": "YU", "english_name": "Yugoslavia"},
]
def test_countries_stored_before_codes_get_them_from_tmdbs_list(db):
lib = _lib(db)
old = PlexItem(
rating_key="m1",
library_id=lib.id,
kind="movie",
title="Old",
production_countries=["Yugoslavia", "Atlantis"],
tmdb_enriched_at=datetime.now(timezone.utc),
)
show_lib = _lib(db, key="2", kind="show")
show = PlexShow(
rating_key="s1",
library_id=show_lib.id,
title="Show",
production_countries=["United States of America"],
)
new = PlexItem(
rating_key="m2",
library_id=lib.id,
kind="movie",
title="New",
production_countries=[{"code": "US", "name": "United States of America"}],
)
db.add_all([old, show, new])
db.commit()
# Served before the backfill: the bare name, with no code — never a crash on the old shape.
assert tmdb.serve_block(db, old, lang="hu")["production_countries"] == [
{"code": None, "name": "Yugoslavia"},
{"code": None, "name": "Atlantis"},
]
fake = FakeCountries()
assert tmdb.backfill_country_codes(db, fake) == 2
db.refresh(old)
db.refresh(show)
assert old.production_countries == [
{"code": "YU", "name": "Yugoslavia"},
{"code": None, "name": "Atlantis"}, # not in TMDB's list → no code, the name kept
]
assert show.production_countries == [{"code": "US", "name": "United States of America"}]
# Nothing left to code: no request at all.
assert tmdb.backfill_country_codes(db, fake) == 0
assert fake.calls == 1
+44
View File
@@ -2,7 +2,10 @@
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
caller can hold a job's "running" claim at a time, and it's claimable again once released.""" caller can hold a job's "running" claim at a time, and it's claimable again once released."""
from sqlalchemy import select
from app import scheduler from app import scheduler
from app.models import Notification
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs _JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
@@ -23,3 +26,44 @@ def test_claim_is_exclusive_until_released():
# ...and now it can be claimed again. # ...and now it can be claimed again.
assert scheduler._claim_running(_JOB) is True assert scheduler._claim_running(_JOB) is True
_release() _release()
# ---- E30: the result as data -------------------------------------------------------------------
def _snapshot_job(job_id):
return next(j for j in scheduler.scheduler_snapshot()["jobs"] if j["id"] == job_id)
def test_a_run_keeps_its_result_as_data_for_the_readers_language(db, make_user):
"""The dashboard and the manual-run notice showed `_summarize`'s English one-liner ("new=3,
failed=0", a Python repr for a nested phase). The result rides along as data, so the client names
it; the one-liner stays as the fallback and the audit text."""
admin = make_user("sched@example.com", role="admin")
token = scheduler._manual_actor.set(admin.id)
try:
scheduler._job("rss_poll", lambda _db: {"new": 3, "failed": 0, "fallback": 1})
scheduler._job("enrich", lambda _db: 5) # enrich returns a bare count
finally:
scheduler._manual_actor.reset(token)
rss = _snapshot_job("rss_poll")
assert rss["result"] == {"new": 3, "failed": 0, "fallback": 1}
assert rss["last_result"] == "new=3, failed=0, fallback=1"
assert _snapshot_job("enrich")["result"] == {"enriched": 5}
notices = db.execute(
select(Notification).where(Notification.user_id == admin.id).order_by(Notification.id)
).scalars()
assert [n.data["result"] for n in notices] == [
{"new": 3, "failed": 0, "fallback": 1},
{"enriched": 5},
]
def test_a_failed_run_drops_the_previous_result(db):
scheduler._job("rss_poll", lambda _db: {"new": 1})
scheduler._job("rss_poll", lambda _db: (_ for _ in ()).throw(RuntimeError("boom")))
rss = _snapshot_job("rss_poll")
assert rss["last_error"] == "boom"
assert rss["result"] is None
+5 -1
View File
@@ -728,7 +728,11 @@ function AdminInvites() {
<div key={i.id} className="flex items-center gap-2"> <div key={i.id} className="flex items-center gap-2">
<span className="truncate flex-1">{i.email}</span> <span className="truncate flex-1">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-danger"}> <span className={i.status === "approved" ? "text-accent" : "text-danger"}>
{i.status} {t(
i.status === "approved"
? "settings.invites.status.approved"
: "settings.invites.status.denied",
)}
</span> </span>
{/* Only an approved row with NOBODY behind it yet: this button means "the door is {/* Only an approved row with NOBODY behind it yet: this button means "the door is
open, come in", which is meaningless to someone already inside — and half the open, come in", which is meaningless to someone already inside — and half the
+12 -20
View File
@@ -3,35 +3,27 @@ import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api"; import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify"; import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge"; import { Badge } from "./ui/Badge";
import { languageName, regionName, vocab } from "../lib/vocab";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) --- // --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
function displayName(code: string, lang: string, type: "region" | "language"): string {
try {
return (
new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ??
code
);
} catch {
return code;
}
}
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play". // YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
function parseKeywords(s: string): string[] { function parseKeywords(s: string): string[] {
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean); return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
} }
// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated. // topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
function topicLabels(urls: string[]): string[] { // slug is the vocabulary word (E30): named in the reader's language, else shown with spaces.
function topicSlugs(urls: string[]): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
for (const url of urls) { for (const url of urls) {
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url; const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
let label = seg; let slug = seg;
try { try {
label = decodeURIComponent(seg); slug = decodeURIComponent(seg);
} catch { } catch {
/* keep raw */ /* keep raw */
} }
label = label.replace(/_/g, " ").trim(); slug = slug.trim();
if (label) seen.add(label); if (slug) seen.add(slug);
} }
return [...seen]; return [...seen];
} }
@@ -43,7 +35,7 @@ function topicLabels(urls: string[]): string[] {
*/ */
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const topics = ch.topic_categories?.length ? topicLabels(ch.topic_categories) : []; const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
const keywords = ch.keywords ? parseKeywords(ch.keywords) : []; const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
const hasAboutDetails = const hasAboutDetails =
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0; !!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
@@ -86,14 +78,14 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
aria-hidden aria-hidden
/> />
)} )}
{displayName(ch.country, i18n.language, "region")} {regionName(t, ch.country, i18n.language)}
</span> </span>
</div> </div>
)} )}
{ch.default_language && ( {ch.default_language && (
<div className="flex gap-2"> <div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span> <span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
<span>{displayName(ch.default_language, i18n.language, "language")}</span> <span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
</div> </div>
)} )}
{topics.length > 0 && ( {topics.length > 0 && (
@@ -102,7 +94,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{/* Future search epic: these topic chips become clickable filters. */} {/* Future search epic: these topic chips become clickable filters. */}
{topics.map((tp) => ( {topics.map((tp) => (
<Badge key={tp}>{tp}</Badge> <Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
))} ))}
</div> </div>
</div> </div>
+3 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll"; import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -42,6 +42,7 @@ import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { IconLink } from "./ui/LinkButton"; import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings"; import { PageHeading } from "./ui/Headings";
import { useTags } from "../lib/useTags";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -230,11 +231,7 @@ export default function ChannelPage({
// invalidates the manager list so a tag added here shows there too. // invalidates the manager list so a tag added here shows there too.
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an // Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
// unsubscribed/demo channel page never shows the control, so the request would be thrown away. // unsubscribed/demo channel page never shows the control, so the request would be thrown away.
const tagsQuery = useQuery({ const tagsQuery = useTags({ enabled: !!ch?.subscribed && !me.is_demo });
queryKey: qk.tags(),
queryFn: api.tags,
enabled: !!ch?.subscribed && !me.is_demo,
});
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({ const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) => optimisticToggle: (id, tagId, attach) =>
+2 -1
View File
@@ -54,6 +54,7 @@ import { useNavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useWizard } from "./wizardContext"; import { useWizard } from "./wizardContext";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { useTags } from "../lib/useTags";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
@@ -118,7 +119,7 @@ export default function Channels() {
}; };
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); const tagsQuery = useTags();
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels }); const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
const [tagManagerOpen, setTagManagerOpen] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false);
+2 -1
View File
@@ -12,6 +12,7 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav"; import { navigateTo } from "../lib/nav";
import { profileName } from "../lib/vocab";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
@@ -75,7 +76,7 @@ export default function DownloadDialog({
hideLabel hideLabel
selected={chosen == null ? [] : [String(chosen)]} selected={chosen == null ? [] : [String(chosen)]}
onChange={([v]) => v && setProfileId(Number(v))} onChange={([v]) => v && setProfileId(Number(v))}
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))} options={profiles.map((p) => ({ value: String(p.id), label: profileName(t, p) }))}
/> />
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4"> <Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
{t("downloads.dialog.manage")} {t("downloads.dialog.manage")}
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// E30: a render crash's notice speaks the reader's language. It showed `error.message` — the JS
// engine's English ("Cannot read properties of undefined (reading 'x')") — in the toast and the inbox,
// where it outlives the reload. The technical text stays in the `ui.crash` log.
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
const notify = vi.fn();
vi.mock("../lib/notifications", () => ({ notify: (...args: unknown[]) => notify(...args) }));
import i18n from "../i18n";
import { clear, events } from "../lib/log";
import ErrorBoundary from "./ErrorBoundary";
afterEach(() => {
cleanup();
clear();
vi.restoreAllMocks();
});
it("a crash is reported in words the reader reads; the engine's text goes to the log", () => {
vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the throw itself too
const Broken = () => {
throw new TypeError("Cannot read properties of undefined (reading 'x')");
};
render(
<ErrorBoundary>
<Broken />
</ErrorBoundary>,
);
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "fatal", message: i18n.t("errors.boundary.notifMessage") }),
);
expect(JSON.stringify(events().filter((e) => e.event === "ui.crash"))).toContain(
"Cannot read properties of undefined",
);
});
+3 -1
View File
@@ -32,7 +32,9 @@ export default class ErrorBoundary extends Component<Props, State> {
notify({ notify({
level: "fatal", level: "fatal",
title: i18n.t("errors.boundary.notifTitle"), title: i18n.t("errors.boundary.notifTitle"),
message: error.message || i18n.t("errors.boundary.notifMessage"), // Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
// is no message for the reader (E30) — the `ui.crash` log above carries it for diagnosis.
message: i18n.t("errors.boundary.notifMessage"),
requiresInteraction: true, requiresInteraction: true,
}); });
} }
@@ -330,3 +330,37 @@ describe("NotificationsPanel · the page's background menu", () => {
expect(menuRows()).toEqual(["inbox.markAllRead"]); expect(menuRows()).toEqual(["inbox.markAllRead"]);
}); });
}); });
describe("a download-lifecycle notice (E30)", () => {
it("is named from its data in the reader's language, never the server's English title", () => {
h.server = [
server(1, {
type: "download_shared",
title: "Cats was shared with you",
body: null,
data: { kind: "download_shared", from: "a@b.c", title: "Cats" },
} as never),
server(2, {
type: "download_expiring",
title: "Dogs",
data: { kind: "download_expiring", title: "Dogs" },
} as never),
server(3, {
type: "download_expired",
title: "download", // an untitled video: the server's English fallback word
data: { kind: "download_expired", title: null },
} as never),
server(4, {
type: "download_evicted",
title: "Birds",
data: { kind: "download_evicted", title: "Birds" },
} as never),
];
mount();
for (const key of ["downloadShared", "downloadExpiring", "downloadExpired", "downloadEvicted"])
expect(screen.getByText(`inbox.${key}.title`)).toBeTruthy();
for (const title of ["Cats", "Dogs", "Birds"]) expect(screen.getByText(title)).toBeTruthy();
expect(screen.queryByText("Cats was shared with you")).toBeNull();
expect(screen.queryByText("download")).toBeNull();
});
});
+23 -4
View File
@@ -18,7 +18,8 @@ import {
UserPlus, UserPlus,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type AppNotification } from "../lib/api"; import { api, type AppNotification, type JobResult } from "../lib/api";
import { jobResultText } from "../lib/jobResult";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab"; import { focusAccessRequestsTab } from "../lib/adminUsersTab";
@@ -438,6 +439,14 @@ export default function NotificationsPanel() {
} }
/** A server notification's link, from its type and typed payload (the row's button and its menu). */ /** A server notification's link, from its type and typed payload (the row's button and its menu). */
// The download-lifecycle notification types → their `inbox.<key>` copy.
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
download_shared: "downloadShared",
download_expiring: "downloadExpiring",
download_expired: "downloadExpired",
download_evicted: "downloadEvicted",
};
function serverKind(n: AppNotification): "scheduler" | "download" | null { function serverKind(n: AppNotification): "scheduler" | "download" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler"; if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
if (n.type === "download_done") return "download"; if (n.type === "download_done") return "download";
@@ -508,6 +517,8 @@ function NotificationRow({
const isScheduler = serverKind(n) === "scheduler"; const isScheduler = serverKind(n) === "scheduler";
const isDownloadDone = serverKind(n) === "download"; const isDownloadDone = serverKind(n) === "download";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string"; const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
// A download's life after it is done (E30): shared with you, about to expire, expired, evicted.
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
let title = n.title; let title = n.title;
let body = n.body; let body = n.body;
if (isMaintenance) { if (isMaintenance) {
@@ -516,13 +527,14 @@ function NotificationRow({
title = t("inbox.maintenance.title"); title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: d.count }); body = t("inbox.maintenance.body", { count: d.count });
} else if (isScheduler) { } else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body. // "<Job label> finished/failed", with the result as the body — named in the reader's language
const d = n.data as { job_id: string; status?: string; summary?: string }; // when it rode along as data (E30), else the English summary of an older notice.
const d = n.data as { job_id: string; status?: string; summary?: string; result?: JobResult };
const job = t(`scheduler.jobs.${d.job_id}`, d.job_id); const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
job, job,
}); });
body = d.summary || n.body; body = d.result ? jobResultText(t, d.result) : d.summary || n.body;
} else if (isEmailUndelivered) { } else if (isEmailUndelivered) {
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name // Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
// the message that was lost, since that is what tells the operator what their users didn't get. // the message that was lost, since that is what tells the operator what their users didn't get.
@@ -534,6 +546,13 @@ function NotificationRow({
: "inbox.emailUndelivered.sendFailed", : "inbox.emailUndelivered.sendFailed",
{ subject: d.subject || n.body || "" }, { subject: d.subject || n.body || "" },
); );
} else if (lifecycle) {
// The server stores an English title ("… was shared with you", or the bare word "download" for an
// untitled video); the notice is named from `data`, the video's title as the body — none when it
// has no title (or the share notice predates the title riding along).
const d = n.data as { title?: string | null; from?: string };
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
body = d.title || null;
} else if (isDownloadDone) { } else if (isDownloadDone) {
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body // "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled. // (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
@@ -64,7 +64,11 @@ const detail: PlexItemDetail = {
{ name: "Sam Writer", job: "Writer" }, { name: "Sam Writer", job: "Writer" },
], ],
studios: ["Studio A", "Studio B"], studios: ["Studio A", "Studio B"],
production_countries: ["United States"], // One coded (named in the reader's language), one from a row enriched before codes were stored.
production_countries: [
{ code: "HU", name: "Hungary" },
{ code: null, name: "United States" },
],
recommendations: [], recommendations: [],
backdrops: [], backdrops: [],
}, },
@@ -93,7 +97,8 @@ describe("PlexInfo enriched detail (page)", () => {
expect(screen.getByText("$160M")).toBeTruthy(); expect(screen.getByText("$160M")).toBeTruthy();
expect(screen.getByText("$830M")).toBeTruthy(); expect(screen.getByText("$830M")).toBeTruthy();
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
expect(screen.getByText("United States")).toBeTruthy(); // E30: a coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy();
}); });
it("keyword chips filter the library on click", () => { it("keyword chips filter the library on click", () => {
@@ -127,6 +132,8 @@ describe("PlexInfo enriched detail (page)", () => {
const onOpenPerson = vi.fn(); const onOpenPerson = vi.fn();
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />); wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
expect(screen.getByText("plex.info.crew")).toBeTruthy(); expect(screen.getByText("plex.info.crew")).toBeTruthy();
// E30: the job is TMDB's English word, named through the vocab (key-echo i18n here).
expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy();
fireEvent.click(screen.getByText("Jane Director")); fireEvent.click(screen.getByText("Jane Director"));
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director"); expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
}); });
+5 -2
View File
@@ -28,6 +28,7 @@ import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu"; import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions"; import { usePlexCardActions } from "./usePlexCardActions";
import { toggleWatchState } from "../lib/plexWatch"; import { toggleWatchState } from "../lib/plexWatch";
import { regionName, vocab } from "../lib/vocab";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -233,7 +234,9 @@ function FactsPanel({
rows.push({ rows.push({
key: "countries", key: "countries",
label: t("plex.info.facts.countries"), label: t("plex.info.facts.countries"),
node: tmdb.production_countries.join(", "), node: tmdb.production_countries
.map((c) => regionName(t, c.code, i18n.language, c.name))
.join(", "),
}); });
if (rows.length === 0) return null; if (rows.length === 0) return null;
return ( return (
@@ -309,7 +312,7 @@ function CrewPanel({
) : ( ) : (
<div className="truncate text-sm font-medium">{c.name}</div> <div className="truncate text-sm font-medium">{c.name}</div>
)} )}
<div className="truncate text-2xs text-muted">{c.job}</div> <div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
</div> </div>
))} ))}
</div> </div>
@@ -113,7 +113,8 @@ describe("PlexPersonView", () => {
expect(screen.getByText("plex.person.shows")).toBeTruthy(); expect(screen.getByText("plex.person.shows")).toBeTruthy();
// E5 S6 bio hero: department, biography, birth facts, aka. // E5 S6 bio hero: department, biography, birth facts, aka.
expect(screen.getByText("Acting")).toBeTruthy(); // E30: TMDB's English department, named through the vocab (key-echo i18n here).
expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
expect(screen.getByText("A celebrated actor.")).toBeTruthy(); expect(screen.getByText("A celebrated actor.")).toBeTruthy();
expect(screen.getByText(/1970-01-01/)).toBeTruthy(); expect(screen.getByText(/1970-01-01/)).toBeTruthy();
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy(); expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
+28 -15
View File
@@ -27,7 +27,7 @@ import {
Volume2, Volume2,
VolumeX, VolumeX,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError } from "../lib/api"; import { api, HttpError, type PlexTrackNaming } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { import {
audioOrdForLang, audioOrdForLang,
@@ -88,6 +88,7 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Slider } from "./ui/NativeControls"; import { Slider } from "./ui/NativeControls";
import { formatUnit } from "../lib/format"; import { formatUnit } from "../lib/format";
import { trackLabel } from "../lib/vocab";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo")); const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -131,13 +132,16 @@ const DOUBLE_TAP_MS = 300;
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean }; type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) { export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it // `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change // through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume // rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
// position (losing live progress). See the detail effect's deps. // position (losing live progress). See the detail effect's deps.
const tRef = useRef(t); const tRef = useRef(t);
tRef.current = t; tRef.current = t;
// E30: a track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
const qc = useQueryClient(); const qc = useQueryClient();
const [id, setId] = useState(itemId); const [id, setId] = useState(itemId);
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid. // Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
@@ -1238,16 +1242,21 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
audio: () => { audio: () => {
const ord = cycleAudio(); const ord = cycleAudio();
if (ord === undefined) return; // one track (or none): nothing to say if (ord === undefined) return; // one track (or none): nothing to say
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label; const track = detail?.audio_streams.find((a) => a.ord === (ord ?? 0));
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`); flashOsd(
`${t("plex.player.audio")} · ${track ? trackLabel(t, track, "audio", i18n.language) : "—"}`,
);
}, },
subtitle: () => { subtitle: () => {
const ord = cycleSubtitle(); const ord = cycleSubtitle();
if (ord === undefined) return; if (ord === undefined) return;
const track = ord == null ? null : detail?.subtitle_streams.find((x) => x.ord === ord);
const label = const label =
ord == null ord == null
? t("plex.player.subOff") ? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—"); : track
? trackLabel(t, track, "subtitle", i18n.language)
: "—";
flashOsd(`${t("plex.player.subtitles")} · ${label}`); flashOsd(`${t("plex.player.subtitles")} · ${label}`);
}, },
info: () => setInfoOpen((v) => !v), info: () => setInfoOpen((v) => !v),
@@ -1309,6 +1318,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
cycleSubtitle, cycleSubtitle,
flashOsd, flashOsd,
t, t,
i18n.language,
detail, detail,
prevId, prevId,
nextId, nextId,
@@ -1336,6 +1346,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0; const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
// While scrubbing the head follows the finger (preview); otherwise it tracks playback. // While scrubbing the head follows the finger (preview); otherwise it tracks playback.
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct; const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
// The tracks playing now, for the stats overlay.
const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0));
const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd);
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock, // Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the // BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
@@ -1412,7 +1425,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
kind="subtitles" kind="subtitles"
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)} src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
srcLang={selectedSub.language ?? undefined} srcLang={selectedSub.language ?? undefined}
label={selectedSub.label} label={subtitleName(selectedSub)}
{...subTrackListeners} {...subTrackListeners}
default default
/> />
@@ -1466,7 +1479,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
) : ( ) : (
<> <>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden /> <Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
<span>{t("plex.player.subPreparing", { label: selectedSub.label })}</span> <span>{t("plex.player.subPreparing", { label: subtitleName(selectedSub) })}</span>
</> </>
)} )}
</div> </div>
@@ -1783,14 +1796,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
tone="media" tone="media"
role="menuitemradio" role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord} checked={(audioOrd ?? 0) === a.ord}
label={a.label} label={audioName(a)}
onSelect={() => changeAudio(a.ord)} onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${ className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white" (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`} }`}
> >
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} /> <MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{a.label}</span> <span className="truncate">{audioName(a)}</span>
</PopoverItem> </PopoverItem>
))} ))}
</div> </div>
@@ -1815,14 +1828,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
tone="media" tone="media"
role="menuitemradio" role="menuitemradio"
checked={subOrd === sub.ord} checked={subOrd === sub.ord}
label={sub.label} label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)} onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${ className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white" subOrd === sub.ord ? "text-accent" : "text-white"
}`} }`}
> >
<MenuCheck kind="one" on={subOrd === sub.ord} /> <MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{sub.label}</span> <span className="truncate">{subtitleName(sub)}</span>
</PopoverItem> </PopoverItem>
))} ))}
</div> </div>
@@ -1896,13 +1909,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
videoRef={videoRef} videoRef={videoRef}
hlsRef={hlsRef} hlsRef={hlsRef}
fragMeterRef={fragMeterRef} fragMeterRef={fragMeterRef}
audioLabel={ audioLabel={currentAudio ? audioName(currentAudio) : undefined}
detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined
}
subLabel={ subLabel={
subOrd == null subOrd == null
? t("plex.player.subOff") ? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined) : currentSub
? subtitleName(currentSub)
: undefined
} }
playable={detail?.playable ?? ""} playable={detail?.playable ?? ""}
hwRef={hwRef} hwRef={hwRef}
+2 -1
View File
@@ -6,6 +6,7 @@ import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { profileName } from "../lib/vocab";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
@@ -118,7 +119,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
{builtins.map((p) => ( {builtins.map((p) => (
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}> <div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
<Lock className="w-3.5 h-3.5 text-muted shrink-0" /> <Lock className="w-3.5 h-3.5 text-muted shrink-0" />
<span className="flex-1 truncate">{p.name}</span> <span className="flex-1 truncate">{profileName(t, p)}</span>
</div> </div>
))} ))}
{mine.length > 0 && ( {mine.length > 0 && (
@@ -32,6 +32,7 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
last_started: null, last_started: null,
last_finished: null, last_finished: null,
last_result: null, last_result: null,
result: null,
last_error: null, last_error: null,
progress: null, progress: null,
}, },
@@ -258,3 +259,25 @@ describe("Scheduler while paused (E28 audit F13)", () => {
expect(runOne).not.toHaveBeenCalled(); expect(runOne).not.toHaveBeenCalled();
}); });
}); });
describe("a job's last result (E30)", () => {
it("is named from its data, not the server's English one-liner", async () => {
const base = status();
await mount({
...base,
jobs: [
{
...base.jobs[0]!,
last_finished: new Date().toISOString(),
last_result: "new=3, failed=0",
result: { new: 3, failed: 0 },
},
],
});
// Key-echo i18n: the keys named, the counts shown.
expect(
await screen.findByText(/scheduler\.result\.new: 3 · scheduler\.result\.failed: 0/),
).toBeTruthy();
expect(screen.queryByText(/new=3/)).toBeNull();
});
});
@@ -30,6 +30,7 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
last_started: null, last_started: null,
last_finished: null, last_finished: null,
last_result: null, last_result: null,
result: null,
last_error: null, last_error: null,
progress: null, progress: null,
}, },
+3
View File
@@ -35,6 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu"; import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton"; import { MoreButton } from "./ui/MoreButton";
import { type ActionMenuItem } from "./ui/ActionMenu"; import { type ActionMenuItem } from "./ui/ActionMenu";
import { jobResultText } from "../lib/jobResult";
const POLL_MS = 4000; const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks. // While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -255,6 +256,8 @@ function JobRow({
: t("scheduler.neverRun")} : t("scheduler.neverRun")}
{job.last_error ? ( {job.last_error ? (
<span className="text-danger"> · {job.last_error}</span> <span className="text-danger"> · {job.last_error}</span>
) : job.result ? (
<span> · {jobResultText(t, job.result)}</span>
) : job.last_result ? ( ) : job.last_result ? (
<span> · {job.last_result}</span> <span> · {job.last_result}</span>
) : null} ) : null}
+2 -1
View File
@@ -21,6 +21,7 @@ import MatchToggle from "./ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "./channelsContext"; import { useChannelsActions } from "./channelsContext";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { useTags } from "../lib/useTags";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [ const DATE_PRESETS: { days: number; key: string }[] = [
@@ -53,7 +54,7 @@ export default function Sidebar({
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions(); const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); const tagsQuery = useTags();
const tags = tagsQuery.data ?? []; const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context. Debounce the search term so // Live per-tag channel counts for the current filter context. Debounce the search term so
+2 -1
View File
@@ -16,6 +16,7 @@ import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { useTags } from "../lib/useTags";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a // One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the // hover popover on the count listing the tagged channels (each a link that focuses it in the
@@ -112,7 +113,7 @@ export default function TagManager({
const confirm = useConfirm(); const confirm = useConfirm();
const listFade = useScrollFade(); const listFade = useScrollFade();
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); const tagsQuery = useTags();
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) => const channelsForTag = (tagId: number) =>
+2 -5
View File
@@ -10,6 +10,7 @@ import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge"; import { Badge } from "./ui/Badge";
import { useTags } from "../lib/useTags";
// A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a // A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a
// description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into // description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into
@@ -58,11 +59,7 @@ export default function VideoDetailsAt({
enabled: !!video.channel_id, enabled: !!video.channel_id,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const { data: tags } = useQuery({ const { data: tags } = useTags({ staleTime: 5 * 60_000 });
queryKey: qk.tags(),
queryFn: () => api.tags(),
staleTime: 5 * 60_000,
});
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list. // The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops. // These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
@@ -162,6 +162,9 @@ describe("Access requests — the delete affordance", () => {
await openAccessTab(); await openAccessTab();
fireEvent.click(await screen.findByText(/settings\.invites\.decided/)); fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
// E30: the decision is named, not the raw status word ("denied").
expect(screen.getByText("settings.invites.status.denied")).toBeTruthy();
expect(screen.queryByText("denied")).toBeNull();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1)); await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1));
+4 -1
View File
@@ -57,6 +57,7 @@ import { Chip } from "./ui/Chip";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings"; import { PageHeading } from "./ui/Headings";
import { vocab } from "../lib/vocab";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -243,7 +244,9 @@ export function PlexPersonView({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading> <PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
{d.known_for_department && ( {d.known_for_department && (
<p className="text-xs font-medium text-accent">{d.known_for_department}</p> <p className="text-xs font-medium text-accent">
{vocab(t, "department", d.known_for_department)}
</p>
)} )}
<p className="mt-1 mb-2 text-xs text-muted"> <p className="mt-1 mb-2 text-xs text-muted">
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })} {t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
@@ -1,5 +1,6 @@
import { readdirSync, readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { vocabKey } from "../lib/vocab";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these // Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
// up with the raw id as the fallback — `t(\`config.fields.${key}.label\`, key)` — so a missing key never // up with the raw id as the fallback — `t(\`config.fields.${key}.label\`, key)` — so a missing key never
@@ -147,6 +148,45 @@ const youtubeReasons = [
).matchAll(/"(\w+)"/g), ).matchAll(/"(\w+)"/g),
].map((m) => m[1]!); ].map((m) => m[1]!);
// E30: 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 —
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
// create, and the built-in download profiles of the latest migration that seeds them.
const pyStrings = (block: string | undefined, re: RegExp) =>
[...(block ?? "").matchAll(re)].map((m) => m[1]!);
const between = (src: string, start: string, end: string) => src.split(start)[1]?.split(end)[0];
const tmdbPy = backend("plex/tmdb.py");
const crewJobs = [
...new Set([
...pyStrings(between(tmdbPy, "_MOVIE_CREW_JOBS = (", ")"), /"([^"]+)"/g),
...pyStrings(between(tmdbPy, "_TV_CREW_JOBS = (", ")"), /"([^"]+)"/g),
]),
];
const autotagPy = backend("sync/autotag.py");
const autoTags = [
...new Set([
...pyStrings(between(autotagPy, "LANG_NAMES = {", "}"), /:\s*"([^"]+)"/g),
...pyStrings(between(autotagPy, "TOPIC_COLORS = {", "}"), /"([^"]+)":/g),
...pyStrings(between(autotagPy, "_TOPIC_SLUGS = {", "}"), /:\s*"([^"]+)"/g),
...pyStrings(between(autotagPy, "_CATEGORY_NAMES = {", "}"), /:\s*"([^"]+)"/g),
...pyStrings(between(autotagPy, "def map_topic_slug", "\n\n\n"), /return "([^"]+)"/g),
]),
];
const migrations = readdirSync(new URL("../../../backend/alembic/versions/", import.meta.url))
.filter((f) => f.endsWith(".py"))
.sort();
const seeding = migrations
.map((f) =>
readFileSync(new URL(`../../../backend/alembic/versions/${f}`, import.meta.url), "utf-8"),
)
.filter((src) => src.includes("_BUILTINS = ["))
.pop();
const builtinProfiles = pyStrings(
between(seeding ?? "", "_BUILTINS = [", "\n]"),
/^\s+\("([^"]+)",/gm,
);
const VOCAB = { crewJob: crewJobs, autoTag: autoTags, profile: builtinProfiles };
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */ /** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
const idsUnder = (keys: Set<string>, prefix: string) => const idsUnder = (keys: Set<string>, prefix: string) =>
new Set( new Set(
@@ -170,6 +210,13 @@ 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"]); expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
expect(crewJobs).toEqual(
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
);
expect(autoTags).toEqual(
expect.arrayContaining(["English", "Gaming", "Health & Fitness", "Hobbies", "Sports"]),
);
expect(builtinProfiles).toEqual(expect.arrayContaining(["720p (MP4)", "Video only (MP4)"]));
expect(refusedReasons).toEqual( expect(refusedReasons).toEqual(
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]), expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
); );
@@ -254,6 +301,17 @@ describe("backend ids have their names in both languages", () => {
).toEqual([]); ).toEqual([]);
}); });
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)) {
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
expect(missing(wanted), group).toEqual([]);
expect(
[...keys].filter((k) => k.startsWith(`vocab.${group}.`) && !wanted.includes(k)),
group,
).toEqual([]);
}
});
it(`${lang}: every admin refusal reason`, () => { it(`${lang}: every admin refusal reason`, () => {
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]); expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual( expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
+12
View File
@@ -34,5 +34,17 @@
"title": "Email is not going out", "title": "Email is not going out",
"notConfigured": "SMTP is not configured, so nothing this instance sends is being delivered — verification links, password resets and access requests alike. One of them was “{{subject}}”. Set it up under Configuration → Email / SMTP.", "notConfigured": "SMTP is not configured, so nothing this instance sends is being delivered — verification links, password resets and access requests alike. One of them was “{{subject}}”. Set it up under Configuration → Email / SMTP.",
"sendFailed": "Couldn't send email, so messages are not being delivered. One of them was “{{subject}}”. Check Configuration → Email / SMTP and send a test; the server log has the reason." "sendFailed": "Couldn't send email, so messages are not being delivered. One of them was “{{subject}}”. Check Configuration → Email / SMTP and send a test; the server log has the reason."
},
"downloadShared": {
"title": "{{from}} shared a download with you"
},
"downloadExpiring": {
"title": "A download expires soon"
},
"downloadExpired": {
"title": "A download expired and was removed"
},
"downloadEvicted": {
"title": "A download was removed to free up space"
} }
} }
+66 -1
View File
@@ -113,5 +113,70 @@
"purgeDiscovery": "Purge discovery", "purgeDiscovery": "Purge discovery",
"purgeDiscoveryHint": "Delete all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.", "purgeDiscoveryHint": "Delete all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
"purgeConfirmTitle": "Purge discovery content?", "purgeConfirmTitle": "Purge discovery content?",
"purgedDiscovery": "Deleted {{count}} discovery items" "purgedDiscovery": "Deleted {{count}} discovery items",
"result": {
"new": "new videos",
"failed": "failed",
"fallback": "read through the API",
"channels_processed": "channels processed",
"videos_added": "videos added",
"reconciled_full": "fully reconciled",
"reason": "reason",
"channels_tagged": "channels tagged",
"orphan_tags_removed": "orphan tags removed",
"probed": "checked",
"shorts": "Shorts",
"users": "users",
"playlists_synced": "playlists synced",
"rechecked": "rechecked",
"recovered": "recovered",
"deleted": "deleted",
"notified": "notified",
"revalidated": "revalidated",
"flagged": "flagged",
"skipped": "skipped",
"playlists_seeded": "playlists seeded",
"expired": "expired",
"videos_deleted": "videos deleted",
"channels_deleted": "channels deleted",
"search_videos_deleted": "search results deleted",
"warned": "warned",
"evicted": "evicted",
"reaped": "cleaned up",
"swept": "swept",
"libraries": "libraries",
"movies": "movies",
"shows": "shows",
"seasons": "seasons",
"episodes": "episodes",
"collections": "collections",
"overlaid": "HU overlay",
"pruned": "pruned",
"people_enriched": "people enriched",
"media_probed": "media probed",
"people_pruned": "people pruned",
"error": "error",
"links": "links",
"watched": "watched",
"in_progress": "in progress",
"repushed": "re-sent",
"movie_probes": "movie probes",
"applied": "applied",
"cleared": "cleared",
"scanned": "scanned",
"enriched": "enriched",
"countries_coded": "countries coded",
"sessions_closed": "sessions closed",
"sessions_reaped": "sessions cleaned up",
"recent": "recent",
"deep": "deep"
},
"skip": {
"disabled": "disabled",
"PlexserverURLortokenisnotconfigured": "Plex server URL or token is not configured",
"tmdbapikeynotset": "TMDB API key not set",
"nodemoaccount": "no demo account",
"noserviceuser": "no service user",
"syncpaused": "sync paused"
}
} }
+5 -1
View File
@@ -192,7 +192,11 @@
"deleteFailed": "Couldn't delete the entry", "deleteFailed": "Couldn't delete the entry",
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your Notifications instead, and fix it under Configuration → Email / SMTP.", "emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your Notifications instead, and fix it under Configuration → Email / SMTP.",
"deletedStillAllowed": "Entry deleted, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.", "deletedStillAllowed": "Entry deleted, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so deleting this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in." "deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so deleting this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in.",
"status": {
"approved": "Approved",
"denied": "Denied"
}
}, },
"diagnostics": { "diagnostics": {
"title": "Logging level", "title": "Logging level",
+169
View File
@@ -0,0 +1,169 @@
{
"crewJob": {
"Director": "Director",
"Writer": "Writer",
"Screenplay": "Screenplay",
"Story": "Story",
"Producer": "Producer",
"ExecutiveProducer": "Executive Producer",
"Editor": "Editor",
"DirectorofPhotography": "Director of Photography",
"OriginalMusicComposer": "Original Music Composer",
"Creator": "Creator"
},
"department": {
"Acting": "Acting",
"Directing": "Directing",
"Writing": "Writing",
"Production": "Production",
"Sound": "Sound",
"Camera": "Camera",
"Editing": "Editing",
"Art": "Art",
"CostumeMakeUp": "Costume & Make-Up",
"Crew": "Crew",
"VisualEffects": "Visual Effects",
"Lighting": "Lighting",
"Creator": "Creator"
},
"autoTag": {
"English": "English",
"Hungarian": "Hungarian",
"German": "German",
"French": "French",
"Spanish": "Spanish",
"Italian": "Italian",
"Portuguese": "Portuguese",
"Dutch": "Dutch",
"Russian": "Russian",
"Polish": "Polish",
"Romanian": "Romanian",
"Czech": "Czech",
"Slovak": "Slovak",
"Turkish": "Turkish",
"Swedish": "Swedish",
"Danish": "Danish",
"Norwegian": "Norwegian",
"Finnish": "Finnish",
"Japanese": "Japanese",
"Korean": "Korean",
"Chinese": "Chinese",
"Hindi": "Hindi",
"Arabic": "Arabic",
"Ukrainian": "Ukrainian",
"Greek": "Greek",
"Gaming": "Gaming",
"Music": "Music",
"Cooking": "Cooking",
"Tech": "Tech",
"Education": "Education",
"Sports": "Sports",
"Film": "Film",
"TV": "TV",
"Politics": "Politics",
"HealthFitness": "Health & Fitness",
"Vehicles": "Vehicles",
"Animals": "Animals",
"FashionBeauty": "Fashion & Beauty",
"Travel": "Travel",
"Comedy": "Comedy",
"Entertainment": "Entertainment",
"Business": "Business",
"Science": "Science",
"News": "News",
"Society": "Society",
"PerformingArts": "Performing Arts",
"Military": "Military",
"Religion": "Religion",
"Hobbies": "Hobbies"
},
"profile": {
"720pMP4": "720p (MP4)",
"1080pMP4": "1080p (MP4)",
"BestMP4": "Best (MP4)",
"AudioM4A": "Audio (M4A)",
"AudioMP3": "Audio (MP3)",
"VideoonlyMP4": "Video only (MP4)"
},
"topic": {
"Music": "Music",
"Christianmusic": "Christian music",
"Classicalmusic": "Classical music",
"Countrymusic": "Country music",
"Electronicmusic": "Electronic music",
"Hiphopmusic": "Hip hop music",
"Independentmusic": "Independent music",
"Jazz": "Jazz",
"MusicofAsia": "Music of Asia",
"MusicofLatinAmerica": "Music of Latin America",
"Popmusic": "Pop music",
"Reggae": "Reggae",
"Rhythmandblues": "Rhythm and blues",
"Rockmusic": "Rock music",
"Soulmusic": "Soul music",
"Videogameculture": "Video game culture",
"Actiongame": "Action game",
"Actionadventuregame": "Action-adventure game",
"Casualgame": "Casual game",
"Musicvideogame": "Music video game",
"Puzzlevideogame": "Puzzle video game",
"Racingvideogame": "Racing video game",
"Roleplayingvideogame": "Role-playing video game",
"Simulationvideogame": "Simulation video game",
"Sportsgame": "Sports game",
"Strategyvideogame": "Strategy video game",
"Sport": "Sport",
"Americanfootball": "American football",
"Associationfootball": "Association football",
"Baseball": "Baseball",
"Basketball": "Basketball",
"Boxing": "Boxing",
"Cricket": "Cricket",
"Golf": "Golf",
"Icehockey": "Ice hockey",
"Mixedmartialarts": "Mixed martial arts",
"Motorsport": "Motorsport",
"Professionalwrestling": "Professional wrestling",
"Tennis": "Tennis",
"Volleyball": "Volleyball",
"Entertainment": "Entertainment",
"Humour": "Humour",
"Film": "Film",
"Performingarts": "Performing arts",
"Televisionprogram": "Television program",
"Lifestylesociology": "Lifestyle (sociology)",
"Fashion": "Fashion",
"Physicalfitness": "Physical fitness",
"Food": "Food",
"Hobby": "Hobby",
"Pet": "Pet",
"Physicalattractiveness": "Physical attractiveness",
"Technology": "Technology",
"Tourism": "Tourism",
"Vehicle": "Vehicle",
"Society": "Society",
"Business": "Business",
"Health": "Health",
"Military": "Military",
"Politics": "Politics",
"Religion": "Religion",
"Knowledge": "Knowledge"
},
"region": {
"AN": "Netherlands Antilles",
"SU": "Soviet Union",
"XC": "Czechoslovakia",
"XG": "East Germany",
"XI": "Northern Ireland",
"YU": "Yugoslavia",
"ZR": "Zaire"
},
"track": {
"forced": "forced",
"sdh": "SDH",
"stereo": "Stereo",
"mono": "Mono",
"audioN": "Audio {{n}}",
"subtitleN": "Subtitle {{n}}"
}
}
+12
View File
@@ -34,5 +34,17 @@
"title": "Nem megy ki e-mail", "title": "Nem megy ki e-mail",
"notConfigured": "Az SMTP nincs beállítva, így semmi nem kézbesíthető, amit ez a példány küldene — megerősítő linkek, jelszó-visszaállítások és hozzáférés-kérelmek egyaránt. Az egyik ilyen: „{{subject}}”. Állítsd be a Konfiguráció → E-mail / SMTP fülön.", "notConfigured": "Az SMTP nincs beállítva, így semmi nem kézbesíthető, amit ez a példány küldene — megerősítő linkek, jelszó-visszaállítások és hozzáférés-kérelmek egyaránt. Az egyik ilyen: „{{subject}}”. Állítsd be a Konfiguráció → E-mail / SMTP fülön.",
"sendFailed": "Nem sikerült e-mailt küldeni, így az üzenetek nem kézbesíthetők. Az egyik ilyen: „{{subject}}”. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet; az okot a szerver naplója tartalmazza." "sendFailed": "Nem sikerült e-mailt küldeni, így az üzenetek nem kézbesíthetők. Az egyik ilyen: „{{subject}}”. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet; az okot a szerver naplója tartalmazza."
},
"downloadShared": {
"title": "{{from}} megosztott veled egy letöltést"
},
"downloadExpiring": {
"title": "Hamarosan lejár egy letöltés"
},
"downloadExpired": {
"title": "Egy letöltés lejárt, és törlődött"
},
"downloadEvicted": {
"title": "Egy letöltés helyhiány miatt törlődött"
} }
} }
+66 -1
View File
@@ -113,5 +113,70 @@
"purgeDiscovery": "Felfedezés ürítése", "purgeDiscovery": "Felfedezés ürítése",
"purgeDiscoveryHint": "Most törli az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.", "purgeDiscoveryHint": "Most törli az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
"purgeConfirmTitle": "Felfedezés-tartalom törlése?", "purgeConfirmTitle": "Felfedezés-tartalom törlése?",
"purgedDiscovery": "{{count}} felfedezés-elem törölve" "purgedDiscovery": "{{count}} felfedezés-elem törölve",
"result": {
"new": "új videó",
"failed": "sikertelen",
"fallback": "API-n át olvasva",
"channels_processed": "feldolgozott csatorna",
"videos_added": "hozzáadott videó",
"reconciled_full": "teljesen egyeztetve",
"reason": "ok",
"channels_tagged": "címkézett csatorna",
"orphan_tags_removed": "törölt árva címke",
"probed": "ellenőrizve",
"shorts": "Shorts",
"users": "felhasználó",
"playlists_synced": "szinkronizált lejátszási lista",
"rechecked": "újraellenőrizve",
"recovered": "helyreállt",
"deleted": "törölve",
"notified": "értesítve",
"revalidated": "újraérvényesítve",
"flagged": "megjelölve",
"skipped": "kihagyva",
"playlists_seeded": "létrehozott lejátszási lista",
"expired": "lejárt",
"videos_deleted": "törölt videó",
"channels_deleted": "törölt csatorna",
"search_videos_deleted": "törölt keresési találat",
"warned": "figyelmeztetve",
"evicted": "helyhiány miatt törölve",
"reaped": "eltakarítva",
"swept": "kisöpörve",
"libraries": "könyvtár",
"movies": "film",
"shows": "sorozat",
"seasons": "évad",
"episodes": "epizód",
"collections": "gyűjtemény",
"overlaid": "magyar átfedés",
"pruned": "eltávolítva",
"people_enriched": "kiegészített személy",
"media_probed": "vizsgált média",
"people_pruned": "eltávolított személy",
"error": "hiba",
"links": "kapcsolat",
"watched": "megnézve",
"in_progress": "folyamatban",
"repushed": "újraküldve",
"movie_probes": "filmvizsgálat",
"applied": "alkalmazva",
"cleared": "törölve",
"scanned": "átvizsgálva",
"enriched": "kiegészítve",
"countries_coded": "országkód pótolva",
"sessions_closed": "lezárt munkamenet",
"sessions_reaped": "eltakarított munkamenet",
"recent": "friss",
"deep": "mély"
},
"skip": {
"disabled": "kikapcsolva",
"PlexserverURLortokenisnotconfigured": "a Plex-szerver címe vagy tokenje nincs beállítva",
"tmdbapikeynotset": "nincs TMDB API-kulcs",
"nodemoaccount": "nincs demófiók",
"noserviceuser": "nincs szolgáltatásfiók",
"syncpaused": "a szinkron szünetel"
}
} }
+5 -1
View File
@@ -192,7 +192,11 @@
"deleteFailed": "Nem sikerült törölni a bejegyzést", "deleteFailed": "Nem sikerült törölni a bejegyzést",
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az Értesítéseket figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.", "emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az Értesítéseket figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.", "deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
"deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik." "deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik.",
"status": {
"approved": "Jóváhagyva",
"denied": "Elutasítva"
}
}, },
"diagnostics": { "diagnostics": {
"title": "Naplózási szint", "title": "Naplózási szint",
+169
View File
@@ -0,0 +1,169 @@
{
"crewJob": {
"Director": "Rendező",
"Writer": "Író",
"Screenplay": "Forgatókönyv",
"Story": "Történet",
"Producer": "Producer",
"ExecutiveProducer": "Vezető producer",
"Editor": "Vágó",
"DirectorofPhotography": "Operatőr",
"OriginalMusicComposer": "Zeneszerző",
"Creator": "Alkotó"
},
"department": {
"Acting": "Színészet",
"Directing": "Rendezés",
"Writing": "Írás",
"Production": "Produkció",
"Sound": "Hang",
"Camera": "Operatőri munka",
"Editing": "Vágás",
"Art": "Látványtervezés",
"CostumeMakeUp": "Jelmez és smink",
"Crew": "Stáb",
"VisualEffects": "Vizuális effektek",
"Lighting": "Világítás",
"Creator": "Alkotó"
},
"autoTag": {
"English": "Angol",
"Hungarian": "Magyar",
"German": "Német",
"French": "Francia",
"Spanish": "Spanyol",
"Italian": "Olasz",
"Portuguese": "Portugál",
"Dutch": "Holland",
"Russian": "Orosz",
"Polish": "Lengyel",
"Romanian": "Román",
"Czech": "Cseh",
"Slovak": "Szlovák",
"Turkish": "Török",
"Swedish": "Svéd",
"Danish": "Dán",
"Norwegian": "Norvég",
"Finnish": "Finn",
"Japanese": "Japán",
"Korean": "Koreai",
"Chinese": "Kínai",
"Hindi": "Hindi",
"Arabic": "Arab",
"Ukrainian": "Ukrán",
"Greek": "Görög",
"Gaming": "Videojátékok",
"Music": "Zene",
"Cooking": "Főzés",
"Tech": "Technika",
"Education": "Oktatás",
"Sports": "Sport",
"Film": "Film",
"TV": "TV",
"Politics": "Politika",
"HealthFitness": "Egészség és fitnesz",
"Vehicles": "Járművek",
"Animals": "Állatok",
"FashionBeauty": "Divat és szépség",
"Travel": "Utazás",
"Comedy": "Humor",
"Entertainment": "Szórakozás",
"Business": "Üzlet",
"Science": "Tudomány",
"News": "Hírek",
"Society": "Társadalom",
"PerformingArts": "Előadóművészet",
"Military": "Katonaság",
"Religion": "Vallás",
"Hobbies": "Hobbik"
},
"profile": {
"720pMP4": "720p (MP4)",
"1080pMP4": "1080p (MP4)",
"BestMP4": "Legjobb (MP4)",
"AudioM4A": "Hang (M4A)",
"AudioMP3": "Hang (MP3)",
"VideoonlyMP4": "Csak videó (MP4)"
},
"topic": {
"Music": "Zene",
"Christianmusic": "Keresztény zene",
"Classicalmusic": "Klasszikus zene",
"Countrymusic": "Country",
"Electronicmusic": "Elektronikus zene",
"Hiphopmusic": "Hiphop",
"Independentmusic": "Független zene",
"Jazz": "Jazz",
"MusicofAsia": "Ázsiai zene",
"MusicofLatinAmerica": "Latin-amerikai zene",
"Popmusic": "Popzene",
"Reggae": "Reggae",
"Rhythmandblues": "R&B",
"Rockmusic": "Rockzene",
"Soulmusic": "Soul",
"Videogameculture": "Videojáték-kultúra",
"Actiongame": "Akciójáték",
"Actionadventuregame": "Akció-kalandjáték",
"Casualgame": "Casual játék",
"Musicvideogame": "Zenei videojáték",
"Puzzlevideogame": "Logikai játék",
"Racingvideogame": "Autóversenyzős játék",
"Roleplayingvideogame": "Szerepjáték",
"Simulationvideogame": "Szimulátor",
"Sportsgame": "Sportjáték",
"Strategyvideogame": "Stratégiai játék",
"Sport": "Sport",
"Americanfootball": "Amerikai futball",
"Associationfootball": "Labdarúgás",
"Baseball": "Baseball",
"Basketball": "Kosárlabda",
"Boxing": "Ökölvívás",
"Cricket": "Krikett",
"Golf": "Golf",
"Icehockey": "Jégkorong",
"Mixedmartialarts": "MMA",
"Motorsport": "Motorsport",
"Professionalwrestling": "Pankráció",
"Tennis": "Tenisz",
"Volleyball": "Röplabda",
"Entertainment": "Szórakozás",
"Humour": "Humor",
"Film": "Film",
"Performingarts": "Előadóművészet",
"Televisionprogram": "Tévéműsor",
"Lifestylesociology": "Életmód",
"Fashion": "Divat",
"Physicalfitness": "Fitnesz",
"Food": "Gasztronómia",
"Hobby": "Hobbi",
"Pet": "Háziállatok",
"Physicalattractiveness": "Szépség",
"Technology": "Technológia",
"Tourism": "Turizmus",
"Vehicle": "Járművek",
"Society": "Társadalom",
"Business": "Üzlet",
"Health": "Egészség",
"Military": "Katonaság",
"Politics": "Politika",
"Religion": "Vallás",
"Knowledge": "Tudás"
},
"region": {
"AN": "Holland Antillák",
"SU": "Szovjetunió",
"XC": "Csehszlovákia",
"XG": "Német Demokratikus Köztársaság",
"XI": "Észak-Írország",
"YU": "Jugoszlávia",
"ZR": "Zaire"
},
"track": {
"forced": "kényszerített",
"sdh": "hallássérülteknek",
"stereo": "sztereó",
"mono": "monó",
"audioN": "{{n}}. hangsáv",
"subtitleN": "{{n}}. felirat"
}
}
+6 -1
View File
@@ -190,6 +190,8 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
return s ? `?${s}` : ""; return s ? `?${s}` : "";
} }
export type JobResult = { [key: string]: number | string | JobResult };
export interface SchedulerJob { export interface SchedulerJob {
id: string; id: string;
interval_minutes: number; interval_minutes: number;
@@ -198,7 +200,10 @@ export interface SchedulerJob {
status: "ok" | "error" | "skipped" | null; status: "ok" | "error" | "skipped" | null;
last_started: string | null; last_started: string | null;
last_finished: string | null; last_finished: string | null;
last_result: string | null; last_result: string | null; // the English one-liner — the fallback for a job with no `result`
// The last result as data (E30): counts, a `skipped` / `reason` word, a nested dict per phase —
// named in the reader's language by lib/jobResult.
result: JobResult | null;
last_error: string | null; last_error: string | null;
// Live progress while running (null when idle or for jobs that don't report it). // Live progress while running (null when idle or for jobs that don't report it).
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue). // total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
+15 -6
View File
@@ -73,7 +73,8 @@ export interface PlexTmdb {
keywords: string[]; keywords: string[];
crew: PlexTmdbCrew[]; crew: PlexTmdbCrew[];
studios: string[]; studios: string[];
production_countries: string[]; // ISO code (null on a row enriched before E30 whose name TMDB's list lacked) + TMDB's English name.
production_countries: { code: string | null; name: string }[];
recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[]; recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[];
backdrops: string[]; backdrops: string[];
} }
@@ -132,6 +133,16 @@ export interface PlexMarker {
start_s: number; start_s: number;
end_s: number; end_s: number;
} }
// What the player names an audio/subtitle track from (E30, `lib/vocab` trackLabel). Plex's own title
// (`label`) is in the Plex SERVER's language, so it is only the fallback for a track with no language tag.
export interface PlexTrackNaming {
ord: number;
label: string | null;
lang_tag?: string | null;
forced?: boolean;
hearing_impaired?: boolean;
detail?: string | null; // the codec tail, "AC3 5.1"
}
export interface PlexItemDetail { export interface PlexItemDetail {
id: string; id: string;
kind: "movie" | "episode"; kind: "movie" | "episode";
@@ -157,15 +168,13 @@ export interface PlexItemDetail {
studio?: string | null; studio?: string | null;
tagline?: string | null; tagline?: string | null;
markers: PlexMarker[]; markers: PlexMarker[];
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; audio_streams: (PlexTrackNaming & { language?: string | null; default: boolean })[];
// `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden). // `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
subtitle_streams: { subtitle_streams: (PlexTrackNaming & {
ord: number;
label: string;
language?: string | null; language?: string | null;
codec?: string; codec?: string;
text: boolean; text: boolean;
}[]; })[];
status: string; status: string;
position_seconds: number; position_seconds: number;
show_title?: string | null; show_title?: string | null;
+41
View File
@@ -0,0 +1,41 @@
// @vitest-environment jsdom
//
// E30: a scheduler job's result in the reader's language, through the REAL catalog. The server's own
// one-liner was "recent={'channels_processed': 0, …}, deep={…}" — English keys and a Python repr.
import { afterEach, describe, expect, it } from "vitest";
import i18n, { setLanguage } from "../i18n";
import { jobResultText } from "./jobResult";
afterEach(() => setLanguage("en"));
describe("a job's result", () => {
it("names each count, and a phase's own counts inside it", () => {
setLanguage("hu");
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { new: 3, failed: 0 })).toBe("új videó: 3 · sikertelen: 0");
expect(
jobResultText(t, {
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
}),
).toBe(
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " +
"mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)",
);
});
it("names a skip reason; an upstream error stays its own text", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" })).toBe(
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
);
expect(jobResultText(t, { error: "Plex returned 500" })).toBe("hiba: Plex returned 500");
});
it("shows a key or word it does not know as it is, readable", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" })).toBe(
"brand new count: 3 · kihagyva: moon is full",
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import type { JobResult } from "./api";
import { formatNumber } from "./format";
import { vocabKey } from "./vocab";
/** A scheduler job's result in the reader's language (E30): "új videó: 3 · sikertelen: 0". The server
* stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is
* named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or
* word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */
export function jobResultText(t: TFunction, result: JobResult): string {
return Object.entries(result)
.map(([key, value]) => {
const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") });
if (typeof value === "number") return `${label}: ${formatNumber(value)}`;
if (typeof value === "string")
return key === "error"
? `${label}: ${value}`
: `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`;
return `${label} (${jobResultText(t, value)})`;
})
.join(" · ");
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api"; import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { parseSort } from "./feedSort"; import { parseSort } from "./feedSort";
import { useTags } from "./useTags";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */ /** One narrowing the user has applied, with the label to show and the way to undo just this one. */
export interface ActiveFilter { export interface ActiveFilter {
@@ -47,7 +48,7 @@ export function useActiveFilters(
setFilters: (f: FeedFilters) => void, setFilters: (f: FeedFilters) => void,
): ActiveFilter[] { ): ActiveFilter[] {
const { t } = useTranslation(); const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); const tagsQuery = useTags();
const channelsQuery = useQuery({ const channelsQuery = useQuery({
queryKey: qk.channels(), queryKey: qk.channels(),
queryFn: api.channels, queryFn: api.channels,
+50
View File
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
//
// E30 (D1 A): the tag catalog as every reader sees it. A system (auto) tag is stored in English and
// named in the reader's language; a user's own tag stays as they typed it, even when it happens to be
// a vocabulary word.
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setLanguage } from "../i18n";
import { api, type Tag } from "./api";
import { useTags } from "./useTags";
const tag = (id: number, name: string, system: boolean): Tag => ({
id,
name,
system,
color: null,
category: "topic",
channel_count: 1,
});
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("the tag catalog", () => {
it("names an auto-tag in the reader's language, a user's tag as typed", async () => {
vi.spyOn(api, "tags").mockResolvedValue([
tag(1, "Health & Fitness", true),
tag(2, "Hungarian", true),
tag(3, "Gaming", false), // the user's own, spelled like a vocabulary word
tag(4, "Somethingnew", true), // an auto-tag the catalog does not name yet
]);
setLanguage("hu");
try {
const { result } = renderHook(() => useTags(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data!.map((tg) => tg.name)).toEqual([
"Egészség és fitnesz",
"Magyar",
"Gaming",
"Somethingnew",
]);
} finally {
setLanguage("en");
}
});
});
+20
View File
@@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Tag } from "./api";
import { qk } from "./queryKeys";
import { vocab } from "./vocab";
/** The tag catalog, every reader's ONE way to it: a system (auto) tag is stored under its English name
* ("Health & Fitness", "Hungarian") and named here in the reader's language (E30, decision D1 A — the
* data stays language-neutral, filters and saved views hold ids). A user's own tag is theirs, as typed.
* Naming it once, at the source, is what reaches every chip, column, menu and sort that shows one. */
export function useTags(opts: { enabled?: boolean; staleTime?: number } = {}) {
const { t } = useTranslation();
return useQuery({
queryKey: qk.tags(),
queryFn: api.tags,
...opts,
select: (tags: Tag[]) =>
tags.map((tg) => (tg.system ? { ...tg, name: vocab(t, "autoTag", tg.name) } : tg)),
});
}
+115
View File
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
//
// E30: the server's closed English vocabularies, named in the reader's language — through the REAL
// catalog, so a missing or misnamed key shows here as the English word coming through.
import { describe, expect, it } from "vitest";
import i18n from "../i18n";
import { languageName, profileName, regionName, trackLabel, vocab } from "./vocab";
const hu = i18n.getFixedT("hu");
const en = i18n.getFixedT("en");
describe("a vocabulary word", () => {
it.each([
["crewJob", "Director of Photography", "Operatőr"],
["crewJob", "Executive Producer", "Vezető producer"],
["department", "Acting", "Színészet"],
["autoTag", "Health & Fitness", "Egészség és fitnesz"],
["autoTag", "Hungarian", "Magyar"],
["profile", "Video only (MP4)", "Csak videó (MP4)"],
["topic", "Lifestyle_(sociology)", "Életmód"],
] as const)("%s %j reads %j in Hungarian", (group, word, shown) => {
expect(vocab(hu, group, word)).toBe(shown);
});
it("the English reader sees the word as it was", () => {
expect(vocab(en, "crewJob", "Director of Photography")).toBe("Director of Photography");
expect(vocab(en, "topic", "Pop_music", "Pop music")).toBe("Pop music");
});
it("an unknown word is shown, not hidden or keyed", () => {
expect(vocab(hu, "crewJob", "Gaffer")).toBe("Gaffer");
expect(vocab(hu, "topic", "Knitting_(craft)", "Knitting (craft)")).toBe("Knitting (craft)");
});
});
describe("a country", () => {
it("is named from its code in the reader's language", () => {
expect(regionName(hu, "US", "hu", "United States of America")).toBe("Egyesült Államok");
expect(regionName(en, "DE", "en", "Germany")).toBe("Germany");
});
it("that no longer exists keeps its own name — Intl would call Yugoslavia Serbia", () => {
expect(regionName(hu, "YU", "hu", "Yugoslavia")).toBe("Jugoszlávia");
expect(regionName(hu, "SU", "hu", "Soviet Union")).toBe("Szovjetunió");
expect(regionName(en, "XI", "en", "Northern Ireland")).toBe("Northern Ireland");
});
it("without a code shows the stored name", () => {
expect(regionName(hu, null, "hu", "Hungary")).toBe("Hungary");
});
});
describe("a language", () => {
it("stands alone capitalized, region kept", () => {
expect(languageName("hu", "hu")).toBe("Magyar");
expect(languageName("en-US", "en")).toBe("American English");
});
it("falls back to what was given for a tag Intl cannot name", () => {
expect(languageName("qqq", "hu", "Klingon")).toBe("Klingon");
expect(languageName("und", "hu", "Unknown")).toBe("Unknown"); // "undetermined": no language
});
});
describe("a player track", () => {
const track = { ord: 0, label: "English (AC3 5.1)", lang_tag: "en-US", detail: "AC3 5.1" };
it("is named from its language, not Plex's server-language title", () => {
expect(trackLabel(hu, track, "audio", "hu")).toBe("Angol (AC3 5.1)");
expect(trackLabel(en, track, "audio", "en")).toBe("English (AC3 5.1)");
});
it("says forced / SDH and translates the channel word", () => {
expect(
trackLabel(
hu,
{ ord: 1, label: "magyar Forced", lang_tag: "hu", forced: true },
"subtitle",
"hu",
),
).toBe("Magyar, kényszerített");
expect(
trackLabel(
hu,
{ ord: 2, label: "中文", lang_tag: "zh", detail: "EAC3 Stereo" },
"audio",
"hu",
),
).toBe("Kínai (EAC3 sztereó)");
expect(
trackLabel(
en,
{ ord: 3, label: "English SDH", lang_tag: "en", hearing_impaired: true },
"subtitle",
"en",
),
).toBe("English, SDH");
});
it("with no language keeps Plex's title, and with nothing is numbered", () => {
expect(trackLabel(hu, { ord: 0, label: "Commentary" }, "audio", "hu")).toBe("Commentary");
expect(trackLabel(hu, { ord: 2, label: null, lang_tag: "und" }, "audio", "hu")).toBe(
"3. hangsáv",
);
expect(trackLabel(hu, { ord: 1, label: null }, "subtitle", "hu")).toBe("2. felirat");
expect(trackLabel(en, { ord: 0, label: null }, "audio", "en")).toBe("Audio 1");
});
});
describe("a download profile", () => {
it("built in, is named in the reader's language; the user's own stays as typed", () => {
expect(profileName(hu, { name: "Audio (MP3)", is_builtin: true })).toBe("Hang (MP3)");
expect(profileName(hu, { name: "Audio (MP3)", is_builtin: false })).toBe("Audio (MP3)");
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { TFunction } from "i18next";
import type { PlexTrackNaming } from "./api";
// E30: the closed vocabularies the server stores in ENGLISH — a TMDB crew job or department, an
// auto-tag, a built-in download profile, a YouTube topic — named in the reader's language. The data
// stays language-neutral (it is filtered and matched on); only the NAME is translated, here, from the
// `vocab` locale area. A word the area does not hold shows as stored, so a new server value is never
// hidden — and the backend's own lists are held key-for-key to the area by backendIds.contract.
type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic";
/** A vocabulary word's key: its letters and digits ("Health & Fitness" → "HealthFitness"). */
export function vocabKey(word: string): string {
return word.replace(/[^A-Za-z0-9]/g, "");
}
/** One stored English word of `group`, in the reader's language (`fallback`, by default the word
* itself, when the area does not hold it). */
export function vocab(t: TFunction, group: Group, word: string, fallback = word): string {
const key = vocabKey(word);
return key ? t(`vocab.${group}.${key}`, { defaultValue: fallback }) : fallback;
}
/** A download profile's name: a built-in one is seeded under an English name and named here; a user's
* own is theirs, as typed. */
export function profileName(t: TFunction, p: { name: string; is_builtin: boolean }): string {
return p.is_builtin ? vocab(t, "profile", p.name) : p.name;
}
function intlName(code: string, lang: string, type: "region" | "language"): string | undefined {
try {
return new Intl.DisplayNames([lang], { type, fallback: "none" }).of(code);
} catch {
return undefined; // not a well-formed code
}
}
/** A country from its ISO 3166 code. A code no longer in use (Yugoslavia, the Soviet Union) is
* named from `vocab.region` — Intl renames those to today's state (YU → Serbia) or cannot name them
* (XI, TMDB's Northern Ireland); `fallback` (the stored English name) when neither can. */
export function regionName(
t: TFunction,
code: string | null | undefined,
lang: string,
fallback = "",
): string {
if (!code) return fallback;
const up = code.toUpperCase();
return (
t(`vocab.region.${up}`, { defaultValue: "" }) || intlName(up, lang, "region") || fallback || up
);
}
/** A language from its BCP 47 tag ("en-US" → "American English"), capitalized to stand alone as a
* label ("magyar" → "Magyar"); `fallback` for a tag Intl cannot name ("und", a typo). */
export function languageName(tag: string | null | undefined, lang: string, fallback = ""): string {
const name = tag ? intlName(tag.replace(/_/g, "-"), lang, "language") : undefined;
if (!name || name === tag) return fallback;
return name.charAt(0).toLocaleUpperCase(lang) + name.slice(1);
}
/** An audio or subtitle track's name in the reader's language: "Angol (AC3 5.1)", "Magyar,
* kényszerített". Built from the language tag; a track without one keeps Plex's own title, and a
* track with neither is numbered. */
export function trackLabel(
t: TFunction,
track: PlexTrackNaming,
kind: "audio" | "subtitle",
lang: string,
): string {
// The language alone: Plex tags one track "en-US" and the next "en", and both are just English here.
const name = languageName(track.lang_tag?.split(/[-_]/)[0], lang);
if (!name) return track.label || t(`vocab.track.${kind}N`, { n: track.ord + 1 });
const flags = [
track.forced ? t("vocab.track.forced") : null,
track.hearing_impaired ? t("vocab.track.sdh") : null,
].filter(Boolean);
// The codec tail is technical and language-neutral but for its channel word ("AC3 Stereo").
const detail = track.detail
?.replace(/\bStereo\b/i, t("vocab.track.stereo"))
.replace(/\bMono\b/i, t("vocab.track.mono"));
return [name, ...flags].join(", ") + (detail ? ` (${detail})` : "");
}