fix(plex): the recs rail's anchor and a held recommendation are named in the reader's language (E30 S1b)

Found in the S1b UAT: 'Mert megnézted: Batman Forever' kept the English title, and
the info page's TMDB recommendations kept TMDB's English titles even for titles we
hold. A held recommendation is now named as the library names it, in the reader's
language; one we lack keeps TMDB's title.
This commit is contained in:
2026-09-29 02:25:36 +02:00
parent 632e39d458
commit f6298d29af
5 changed files with 51 additions and 11 deletions
+13 -5
View File
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
from app.models import PlexItem, PlexPerson, PlexShow from app.models import PlexItem, PlexPerson, PlexShow
from app.plex.cards import display_title
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host. # Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
@@ -406,10 +407,11 @@ def resolve_library_guids(
return movies, shows return movies, shows
def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]: def resolve_recs(db: Session, recs: list[dict] | None, *, lang: str) -> list[dict]:
"""Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`, bucketed """Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`, bucketed
by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link
the ones we have and merely display the rest.""" the ones we have and merely display the rest. One we have is named as the library names it, in
`lang` (E30); one we lack keeps the title TMDB gave."""
if not recs: if not recs:
return [] return []
movies, shows = resolve_library_guids(db, recs) movies, shows = resolve_library_guids(db, recs)
@@ -417,13 +419,19 @@ def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]:
for r in recs: for r in recs:
g = f"tmdb:{r.get('tmdb_id')}" g = f"tmdb:{r.get('tmdb_id')}"
row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g) row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g)
named = {"title": display_title(row, lang)} if row is not None else {}
out.append( out.append(
{**r, "in_library": row is not None, "rating_key": row.rating_key if row else None} {
**r,
**named,
"in_library": row is not None,
"rating_key": row.rating_key if row else None,
}
) )
return out return out
def serve_block(db: Session, row) -> dict | None: def serve_block(db: Session, row, *, lang: str) -> dict | None:
"""The TMDB extras for a movie/show detail response, served from the row (no live TMDB call). """The TMDB extras for a movie/show detail response, served from the row (no live TMDB call).
None until the row has been TMDB-enriched (the client then shows nothing extra).""" None until the row has been TMDB-enriched (the client then shows nothing extra)."""
if row.tmdb_enriched_at is None: if row.tmdb_enriched_at is None:
@@ -438,7 +446,7 @@ def serve_block(db: Session, row) -> dict | None:
"crew": row.crew or [], "crew": row.crew or [],
"studios": row.studios or [], "studios": row.studios or [],
"production_countries": row.production_countries or [], "production_countries": row.production_countries or [],
"recommendations": resolve_recs(db, row.recommendations), "recommendations": resolve_recs(db, row.recommendations, lang=lang),
"backdrops": row.backdrops or [], "backdrops": row.backdrops or [],
} }
+2 -2
View File
@@ -211,7 +211,7 @@ def show_detail(
"episode_count": show_roll["episode_count"], "episode_count": show_roll["episode_count"],
"collection_keys": sh.collection_keys or [], "collection_keys": sh.collection_keys or [],
# TMDB enrichment (E5 S2), served from the row. # TMDB enrichment (E5 S2), served from the row.
"tmdb": plex_tmdb.serve_block(db, sh), "tmdb": plex_tmdb.serve_block(db, sh, lang=lang),
}, },
"seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons],
"related": related, "related": related,
@@ -517,5 +517,5 @@ def item_detail(
"next_id": next_id, "next_id": next_id,
"collections": _collection_strips(db, it, user.id, lang), "collections": _collection_strips(db, it, user.id, lang),
# TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched). # TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched).
"tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None, "tmdb": plex_tmdb.serve_block(db, it, lang=lang) if it.kind == "movie" else None,
} }
+5 -2
View File
@@ -313,7 +313,7 @@ def _because_you_watched(
) )
if anchor is None: if anchor is None:
return None return None
resolved = resolve_recs(db, anchor.recommendations) resolved = resolve_recs(db, anchor.recommendations, lang=lang)
# Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself. # Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself.
rks = [ rks = [
r["rating_key"] r["rating_key"]
@@ -362,7 +362,10 @@ def _because_you_watched(
items.append(scards[rk]) items.append(scards[rk])
if not items: if not items:
return None return None
return {"anchor": {"id": anchor.rating_key, "title": anchor.title}, "items": items} return {
"anchor": {"id": anchor.rating_key, "title": display_title(anchor, lang)},
"items": items,
}
@router.get("/discover") @router.get("/discover")
+27
View File
@@ -230,6 +230,25 @@ def _seed_rails(db):
PlexPlaylistItem(playlist_id=pl.id, item_id=ep.id, position=1), PlexPlaylistItem(playlist_id=pl.id, item_id=ep.id, position=1),
] ]
) )
# TMDB extras: m2 recommends m1 (held) and a title we lack; m1, watched, anchors the recs rail.
m1.guid = "tmdb:1"
m1.tmdb_enriched_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
m1.recommendations = [{"tmdb_id": 2, "title": "EN Only", "media_type": "movie"}]
m2 = db.query(PlexItem).filter_by(rating_key="m2").one()
m2.guid = "tmdb:2"
m2.tmdb_enriched_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
m2.recommendations = [
{"tmdb_id": 1, "title": "English Title", "media_type": "movie"},
{"tmdb_id": 77, "title": "Elsewhere", "media_type": "movie"},
]
db.add(
PlexState(
user_id=user.id,
item_id=m1.id,
status="watched",
watched_at=datetime(2024, 3, 1, tzinfo=timezone.utc),
)
)
sysconfig.set_value(db, "plex_enabled", True) sysconfig.set_value(db, "plex_enabled", True)
db.commit() db.commit()
return user, pl.id return user, pl.id
@@ -259,6 +278,14 @@ def test_every_card_rail_serves_the_readers_language(db, monkeypatch, override_d
("Magyar Cím", None), ("Magyar Cím", None),
("Kezdet", "Magyar Sorozat"), ("Kezdet", "Magyar Sorozat"),
] ]
# A recommendation we hold is named as the library names it; the rail's anchor too.
recs = get("/api/plex/item/m2?lang=hu")["tmdb"]["recommendations"]
assert [(r["title"], r["in_library"]) for r in recs] == [
("Magyar Cím", True),
("Elsewhere", False),
]
byw = get("/api/plex/discover?scope=both&lang=hu")["because_you_watched"]
assert byw["anchor"]["title"] == "Magyar Cím"
home = get("/api/discover?lang=hu")["continue_watching"] home = get("/api/discover?lang=hu")["continue_watching"]
plex = [c for c in home if c["source"] == "plex"] plex = [c for c in home if c["source"] == "plex"]
assert [c["title"] for c in plex] == ["Magyar Sorozat"] assert [c["title"] for c in plex] == ["Magyar Sorozat"]
+4 -2
View File
@@ -255,13 +255,13 @@ def test_serve_block_none_until_enriched_and_resolves_recs(db):
db.add_all([sequel, fresh]) db.add_all([sequel, fresh])
db.commit() db.commit()
assert tmdb.serve_block(db, fresh) is None # not enriched yet assert tmdb.serve_block(db, fresh, lang="en") is None # not enriched yet
tmdb.apply_tmdb_detail(fresh, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")) tmdb.apply_tmdb_detail(fresh, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
fresh.tmdb_enriched_at = datetime.now(timezone.utc) fresh.tmdb_enriched_at = datetime.now(timezone.utc)
db.commit() db.commit()
block = tmdb.serve_block(db, fresh) block = tmdb.serve_block(db, fresh, lang="en")
assert block is not None assert block is not None
assert block["budget"] == 6000000 assert block["budget"] == 6000000
assert block["keywords"] == ["mafia", "crime family"] assert block["keywords"] == ["mafia", "crime family"]
@@ -409,6 +409,7 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db):
{"tmdb_id": 500, "title": "Movie 500", "media_type": "movie"}, {"tmdb_id": 500, "title": "Movie 500", "media_type": "movie"},
{"tmdb_id": 900, "title": "Show 900", "media_type": "tv"}, {"tmdb_id": 900, "title": "Show 900", "media_type": "tv"},
], ],
lang="en",
) )
} }
assert miss[500]["in_library"] is False and miss[500]["rating_key"] is None assert miss[500]["in_library"] is False and miss[500]["rating_key"] is None
@@ -423,6 +424,7 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db):
{"tmdb_id": 900, "media_type": "movie"}, {"tmdb_id": 900, "media_type": "movie"},
{"tmdb_id": 500, "media_type": "tv"}, {"tmdb_id": 500, "media_type": "tv"},
], ],
lang="en",
) )
} }
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"