From 519fbe2a18e85925a389d875aaa92e408eb847a2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 29 Sep 2026 23:44:48 +0200 Subject: [PATCH] feat(i18n): the person page's biography in Hungarian, from TMDB's translation (E30 S4) The /person fetch appends translations and keeps TMDB's Hungarian biography beside the instance-language one (new plex_people.biography_hu, migration 0092). A Hungarian reader is served it, the English one when TMDB has none. Already-enriched people are un-stamped, so their next open refetches them once. --- .../versions/0092_plex_person_biography_hu.py | 31 ++++++++ backend/app/models.py | 4 ++ backend/app/plex/tmdb.py | 17 ++++- backend/app/routes/plex/people.py | 5 +- backend/tests/test_plex_person_bio.py | 71 +++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/0092_plex_person_biography_hu.py diff --git a/backend/alembic/versions/0092_plex_person_biography_hu.py b/backend/alembic/versions/0092_plex_person_biography_hu.py new file mode 100644 index 00000000..8f710bb2 --- /dev/null +++ b/backend/alembic/versions/0092_plex_person_biography_hu.py @@ -0,0 +1,31 @@ +"""add biography_hu to plex_people — TMDB's Hungarian biography (E30 S4) + +Revision ID: 0092_plex_person_biography_hu +Revises: 0091_plex_tagline_hu +Create Date: 2026-09-29 + +The person page showed a Hungarian reader the English biography although TMDB has a Hungarian one for +most people. The person fetch now appends `translations` and keeps the Hungarian biography beside the +English one. Already-enriched people are un-stamped, so their next page open refetches them once (the +same lazy path as a first open) and fills it; until then the English biography stands in. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0092_plex_person_biography_hu" +down_revision: str | None = "0091_plex_tagline_hu" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column("plex_people", sa.Column("biography_hu", sa.Text(), nullable=True)) + op.execute( + "UPDATE plex_people SET tmdb_enriched_at = NULL " + "WHERE tmdb_person_id IS NOT NULL AND tmdb_enriched_at IS NOT NULL" + ) + + +def downgrade() -> None: + op.drop_column("plex_people", "biography_hu") diff --git a/backend/app/models.py b/backend/app/models.py index 8c729591..b116f2c6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1432,6 +1432,10 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): # no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id, # media_type, title}], resolved to library rows by guid at serve time (like recommendations). --- biography: Mapped[str | None] = mapped_column(Text) + # TMDB's Hungarian biography (E30 S4), from the same request's `translations`; a Hungarian reader is + # served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and + # keywords have no Hungarian on TMDB, so they stay in the instance language. + biography_hu: Mapped[str | None] = mapped_column(Text) birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is deathday: Mapped[str | None] = mapped_column(String(10)) place_of_birth: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index afba061a..8ff8d09d 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -132,8 +132,11 @@ class TmdbClient: return cast("list[dict] | None", self._get("/configuration/countries")) def fetch_person(self, person_id: int) -> dict | None: - """One person's bio + combined credits (E5 S6), in a single append_to_response request.""" - return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"}) + """One person's bio + combined credits (E5 S6) + the bio's translations (E30 S4), in a single + append_to_response request.""" + return self._get( + f"/person/{person_id}", {"append_to_response": "combined_credits,translations"} + ) def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None: @@ -524,6 +527,7 @@ def parse_person(payload: dict) -> dict: break return { "biography": (payload.get("biography") or "").strip() or None, + "biography_hu": _translated_bio(payload, "hu"), "birthday": payload.get("birthday") or None, "deathday": payload.get("deathday") or None, "place_of_birth": payload.get("place_of_birth") or None, @@ -533,8 +537,17 @@ def parse_person(payload: dict) -> dict: } +def _translated_bio(payload: dict, lang: str) -> str | None: + """The `lang` biography from the appended `translations` (TMDB leaves an untranslated one blank).""" + for tr in (payload.get("translations") or {}).get("translations") or []: + if tr.get("iso_639_1") == lang: + return ((tr.get("data") or {}).get("biography") or "").strip() or None + return None + + def apply_person(row: PlexPerson, parsed: dict) -> None: row.biography = parsed["biography"] + row.biography_hu = parsed["biography_hu"] row.birthday = parsed["birthday"] row.deathday = parsed["deathday"] row.place_of_birth = parsed["place_of_birth"] diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index 3c4b4c38..d5d46481 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -243,7 +243,10 @@ def person_detail( return { "name": name, "photo": person.photo_thumb if person else None, - "biography": person.biography if person else None, + # The Hungarian biography for a Hungarian reader, the instance-language one when TMDB has none. + "biography": ((person.biography_hu if lang == "hu" else None) or person.biography) + if person + else None, "birthday": person.birthday if person else None, "deathday": person.deathday if person else None, "place_of_birth": person.place_of_birth if person else None, diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py index ec18c777..58e6c08b 100644 --- a/backend/tests/test_plex_person_bio.py +++ b/backend/tests/test_plex_person_bio.py @@ -332,3 +332,74 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch, overri assert j["also_known_as"] == ["Alt Name"] # Only the in-library known_for credit resolves to a card; the out-of-library one is dropped. assert [c["title"] for c in j["known_for"]] == ["Known Movie"] + + +# --- E30 S4: the biography in the reader's language -------------------------------------------------- +# The same /person request appends `translations`; the Hungarian one is kept beside the instance-language +# biography, and a Hungarian reader is served it, falling back to the English one when TMDB has none. + + +def _translation(lang: str, bio: str) -> dict: + return {"iso_639_1": lang, "iso_3166_1": lang.upper(), "data": {"biography": bio}} + + +def test_parse_person_keeps_the_hungarian_biography(): + payload = { + **PAYLOAD, + "translations": { + "translations": [ + _translation("de", "Ein Schauspieler."), + _translation("hu", " Ünnepelt színész. "), + ] + }, + } + assert plex_tmdb.parse_person(payload)["biography_hu"] == "Ünnepelt színész." + + +def test_parse_person_without_a_hungarian_biography(): + assert plex_tmdb.parse_person(PAYLOAD)["biography_hu"] is None + blank = {**PAYLOAD, "translations": {"translations": [_translation("hu", " ")]}} + assert plex_tmdb.parse_person(blank)["biography_hu"] is None + + +def test_fetch_person_asks_for_the_translations(): + client = plex_tmdb.TmdbClient.__new__(plex_tmdb.TmdbClient) + seen: list[dict] = [] + client._get = lambda path, params=None: seen.append(params or {}) or {} # type: ignore[method-assign] + client.fetch_person(500) + assert "translations" in seen[0]["append_to_response"].split(",") + + +def test_person_detail_serves_the_biography_in_the_readers_language(db, monkeypatch, override_dep): + from datetime import datetime, timezone + + done = datetime(2024, 1, 1, tzinfo=timezone.utc) + db.add(PlexLibrary(plex_key="7", title="Movies", kind="movie", enabled=True)) + db.add_all( + [ + PlexPerson( + name="Both P", + tmdb_person_id=1, + tmdb_enriched_at=done, + biography="English bio.", + biography_hu="Magyar életrajz.", + ), + PlexPerson( + name="Only EN P", tmdb_person_id=2, tmdb_enriched_at=done, biography="Only English." + ), + ] + ) + user = User(email="biolang@example.com") + db.add(user) + db.commit() + + def bio(name: str, lang: str) -> str | None: + resp = _get(db, user, monkeypatch, override_dep, f"/api/plex/person/{name}?lang={lang}") + assert resp.status_code == 200, resp.text + return resp.json()["biography"] + + assert bio("Both P", "hu") == "Magyar életrajz." + assert bio("Both P", "en") == "English bio." + assert ( + bio("Only EN P", "hu") == "Only English." + ) # TMDB has no Hungarian one: the English stands in