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:
@@ -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 [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(" · ");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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})` : "");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user