fix(plex,audit): E30 review round 1 — the people job keeps going, HU genres everywhere, results as data

- people_enrich: a failed TMDB lookup or a dead headshot is skipped, not the end of the run (a dead
  file is marked and left for a week; three failures in a row end it); portraits wait for the Plex
  module switch; a no-Plex original keeps its own cache key, so the resize still replaces it; the job
  entry lives in the plex domain (run_people_enrich)
- person-image takes only a headshot-shaped CDN url (no query variants of one file)
- every synced row gets Hungarian genres, and a HU genre filter matches the word as picked too;
  migration 0094 fills the rows no overlay reached
- a job run's audit row carries its result as data; audit_clear / demo_reset store their counts
- one credit-column list for the prune and the enrichment order; _overlay_pass typed
- tests: batch skip + stop, HU bio stored, dead headshots, module switch, the person-image route,
  the job body, genre filter + facet write + twin fallback, tagline/summary fallbacks, audit data,
  the reader's alphabet at every remaining collated() call site
This commit is contained in:
2026-09-30 03:26:57 +02:00
parent 59e6cc027d
commit ab963c81d2
15 changed files with 571 additions and 118 deletions
+24
View File
@@ -8,6 +8,9 @@ few hundred MB at most for a big library — acceptable, matching the download d
The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``).
"""
import time
from datetime import timedelta
from fastapi import Response
from sqlalchemy.orm import Session
@@ -78,6 +81,27 @@ def drop(key: str) -> None:
pass
def mark_miss(key: str) -> None:
"""Remember that `key`'s source could not be fetched (an empty `.miss` file; its mtime is when), so
a prefetch can leave a dead source alone for a while. Best-effort."""
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
(CACHE_DIR / f"{key}.miss").touch()
# silent-ok: without the mark the dead source is only asked for again on the next run.
except OSError:
pass
def missed_within(key: str, age: timedelta) -> bool:
"""`key` was marked as a miss less than `age` ago."""
try:
mtime = (CACHE_DIR / f"{key}.miss").stat().st_mtime
# silent-ok: no mark (the usual case).
except OSError:
return False
return time.time() - mtime < age.total_seconds()
def write(key: str, data: bytes, ct: str) -> None:
ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg")
try:
+77 -41
View File
@@ -4,6 +4,8 @@ orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and th
re-sync (plex.sync), so the two paths never diverge."""
import hashlib
import re
from datetime import timedelta
from typing import Literal
from urllib.parse import quote, unquote
@@ -12,7 +14,7 @@ from sqlalchemy import select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import logs
from app import logs, sysconfig
from app.models import PlexPerson
from app.plex import imgcache
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
@@ -97,14 +99,23 @@ def enrich_row_people(db: Session, row, cast: list[dict]) -> bool:
return dirty
# Every credit reference: one person name per (movie/show, credit column). The ONE list of credit
# columns, so the orphan prune and the enrichment order never disagree about who is credited.
_CREDIT_COLUMNS = [
(table, col) for table in ("plex_items", "plex_shows") for col in ("cast_names", "directors")
]
def _credit_refs_sql(union: Literal["UNION", "UNION ALL"]) -> str:
return f" {union} ".join(
f"SELECT jsonb_array_elements_text({col}) FROM {table} WHERE jsonb_typeof({col}) = 'array'"
for table, col in _CREDIT_COLUMNS
)
# The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside
# this set are orphans left by a library removal.
_REFERENCED_PEOPLE_SQL = """
SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array'
UNION SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array'
"""
_REFERENCED_PEOPLE_SQL = _credit_refs_sql("UNION")
def prune_orphan_people(db: Session) -> int:
@@ -125,14 +136,9 @@ def prune_orphan_people(db: Session) -> int:
# How often each person is credited across movies, shows and episodes (cast OR director) — the order the
# people_enrich job works in, so the faces met most often are local first.
_CREDIT_COUNTS_SQL = """
SELECT ref, count(*) AS n FROM (
SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array'
UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array'
UNION ALL SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array'
UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array'
) AS refs(ref) GROUP BY ref
"""
_CREDIT_COUNTS_SQL = (
f"SELECT ref, count(*) AS n FROM ({_credit_refs_sql('UNION ALL')}) AS refs(ref) GROUP BY ref"
)
_NEEDS = {
# linked to TMDB, bio never fetched
"bio": "p.tmdb_person_id IS NOT NULL AND p.tmdb_enriched_at IS NULL",
@@ -160,34 +166,49 @@ def people_by_credits(
# A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder
# fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's
# 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's:
# a new box re-caches instead of serving a stale size, and the full-size original under the old key is
# removed as its small twin lands.
# a new box re-caches instead of serving a stale size. Only a resized image is ever written under it —
# an original fetched without Plex keeps its own key, and goes once its small twin lands.
PORTRAIT_BOX = 320
# A headshot Plex answered with an error for (a dead CDN file) is not asked for again this long, so a
# few dead faces never hold up the rest of the prefetch.
PORTRAIT_RETRY = timedelta(days=7)
# The one shape a Plex CDN headshot has (…/7/people/<hash>.jpg): no query, no fragment. The
# person-image proxy takes nothing else, so it cannot be made to cache endless variants of one file
# (the image cache has no eviction).
_PORTRAIT_URL = re.compile(re.escape(PERSON_IMG_HOST) + r"[A-Za-z0-9_./-]+")
def is_portrait_url(url: str) -> bool:
return _PORTRAIT_URL.fullmatch(url) is not None
def _portrait_keys(url: str) -> tuple[str, str]:
"""(the resized portrait's key, the full-size original's key)."""
base = "person_" + hashlib.sha1(url.encode()).hexdigest()
return f"{base}_{PORTRAIT_BOX}", base
def portrait_cached(url: str) -> tuple[bytes, str] | None:
return imgcache.read(_portrait_keys(url)[0])
"""The cached portrait: the resized one, else an original kept from a time without Plex."""
small, original = _portrait_keys(url)
return imgcache.read(small) or imgcache.read(original)
def cache_portrait(plex: PlexClient | None, url: str) -> tuple[bytes, str]:
"""Fetch one CDN headshot — resized through Plex when a Plex client is given, else the original
straight from the public CDN — and cache it. Raises PlexError / httpx.HTTPError on a failed fetch."""
key, legacy = _portrait_keys(url)
if plex is not None:
data, ct = plex.image_bytes(url, PORTRAIT_BOX, PORTRAIT_BOX)
else:
"""Fetch one CDN headshot and cache it. With a Plex client it is resized (the box key; the original
is removed); without one the original comes straight from the public CDN and is cached under its own
key, so the job still resizes it once Plex is there. Raises PlexError / httpx.HTTPError."""
small, original = _portrait_keys(url)
if plex is None:
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data, ct = r.content, r.headers.get("content-type", "image/jpeg")
imgcache.write(key, data, ct)
if plex is not None:
imgcache.drop(legacy)
imgcache.write(original, data, ct)
return data, ct
data, ct = plex.image_bytes(url, PORTRAIT_BOX, PORTRAIT_BOX)
imgcache.write(small, data, ct)
imgcache.drop(original)
return data, ct
@@ -195,17 +216,23 @@ def cdn_url(photo_thumb: str) -> str | None:
"""The CDN url inside a stored proxied `/person-image?u=` value (see person_thumb_url)."""
_, _, q = photo_thumb.partition("?u=")
url = unquote(q)
return url if url.startswith(PERSON_IMG_HOST) else None
return url if is_portrait_url(url) else None
# Plex not answering this many headshots in a row ends a run: it is Plex that is failing, not the files.
_PLEX_DOWN_AFTER = 3
def warm_portraits(db: Session, limit: int) -> dict:
"""Cache up to `limit` not-yet-cached headshots, the most-credited people first (E30 S6), so a
person's face comes from local disk. Needs Plex (the resize); without it, nothing is prefetched —
full-size originals for every person would cost gigabytes. A failed fetch is skipped; one failing
Plex ends the run."""
person's face comes from local disk. Needs Plex (the resize) and the Plex module on; without them
nothing is prefetched — full-size originals for every person would cost gigabytes. A headshot Plex
answers with an error for is marked and left for PORTRAIT_RETRY; Plex not answering ends the run."""
if not sysconfig.get_bool(db, "plex_enabled"):
return {"cached": 0, "skipped": "disabled"}
try:
plex = PlexClient(db)
# silent-ok: Plex off is a setting, not a failure; the run's result says "disabled".
# silent-ok: no Plex server set up is a setting, not a failure; the run's result says "disabled".
except PlexNotConfigured:
return {"cached": 0, "skipped": "disabled"}
photos: dict[int, str] = {
@@ -215,25 +242,34 @@ def warm_portraits(db: Session, limit: int) -> dict:
).tuples()
if thumb
}
done = failed = 0
done = dead = down = 0
with plex:
for pid in people_by_credits(db, "portrait", None):
if done + failed >= limit:
if done + dead >= limit or down >= _PLEX_DOWN_AFTER:
break
url = cdn_url(photos.get(pid) or "")
if url is None or imgcache.has(_portrait_keys(url)[0]):
if url is None:
continue
key = _portrait_keys(url)[0]
if imgcache.has(key) or imgcache.missed_within(key, PORTRAIT_RETRY):
continue
try:
cache_portrait(plex, url)
done += 1
except (PlexError, httpx.HTTPError) as exc:
failed += 1
done, down = done + 1, 0
except PlexError as exc:
# An error status is Plex's answer about this one file; no answer is Plex itself.
if isinstance(exc.__cause__, httpx.HTTPStatusError):
dead += 1
imgcache.mark_miss(key)
else:
down += 1
log.warning(
"a portrait could not be cached",
extra=logs.event(
"plex.people.portrait_failed", person_id=pid, reason=type(exc).__name__
"plex.people.portrait_failed",
person_id=pid,
reason=type(exc.__cause__).__name__,
),
)
if failed >= 5:
break
failed = dead + down
return {"cached": done, **({"failed": failed} if failed else {})}
+7 -5
View File
@@ -182,13 +182,15 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
Genre chips are language-specific strings, so they filter the active-language genre column."""
conds: list = []
gsel = split_csv(p.get("genres"))
if gsel and lang == "hu":
# The HU column holds one Hungarian word per genre (plex/genres.py); a filter saved while it
# still offered "Action" is read as "Akció", so it keeps matching instead of emptying the grid.
gsel = list(dict.fromkeys(hu_genre(g) for g in gsel))
if gsel:
gcol = genres_col(model, lang)
gc = [gcol.contains([g]) for g in gsel]
if lang == "hu":
# The HU column holds one Hungarian word per genre (plex/genres.py), so a filter saved while
# it offered "Action" is read as "Akció" too. The word as picked still matches as well: a row
# the sync has not reached since keeps its English (or Plex's mixed) list until it does.
gc = [or_(*(gcol.contains([w]) for w in dict.fromkeys((hu_genre(g), g)))) for g in gsel]
else:
gc = [gcol.contains([g]) for g in gsel]
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
crs = split_csv(p.get("content_ratings"))
if crs:
+15 -4
View File
@@ -15,6 +15,7 @@ dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are
item-detail/player path (P2), not here.
"""
from collections.abc import Iterable
from datetime import datetime, timezone
from sqlalchemy import func, select
@@ -449,6 +450,10 @@ def _apply_facet_fields(row, meta: dict) -> None:
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre")
# Every row has Hungarian genres, not only one with an HU overlay twin (the overlay pass, which runs
# after this, puts the twin's own list over it): the HU genre facets and filter read this column,
# and an English word there could never match a filter read in Hungarian.
row.genres_hu = hu_genres(row.genres)
row.directors = _tags(meta, "Director")
# The listing carries only the top ~20 cast, so re-applying it would DOWNGRADE an already-enriched
# full cast (up to 24, grown by the rich backfill/info-page enrich), silently breaking the
@@ -702,13 +707,19 @@ def _sync_overlay(
def _overlay_pass(
db: Session, plex: PlexClient, lib: PlexLibrary, item_type: int, rows, stats: dict
):
db: Session,
plex: PlexClient,
lib: PlexLibrary,
item_type: int,
rows: Iterable[PlexItem] | Iterable[PlexShow],
stats: dict,
) -> None:
"""One kind of the overlay (movies, shows or episodes) — see `_sync_overlay`."""
# guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
by_guid: dict[str, list] = {}
by_guid: dict[str, list[PlexItem | PlexShow]] = {}
for r in rows:
by_guid.setdefault(r.guid, []).append(r)
if r.guid: # the passes select guid-bearing rows only
by_guid.setdefault(r.guid, []).append(r)
# Reconcile step: clear HU fields on all guid-bearing rows. Only rows that actually held HU data
# become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is cheap; matches are re-set below.
# The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking one, from
+29 -8
View File
@@ -24,7 +24,7 @@ from app import logs, sysconfig
from app.config import settings
from app.models import PlexItem, PlexPerson, PlexShow
from app.plex.cards import display_title
from app.plex.people import people_by_credits
from app.plex.people import people_by_credits, warm_portraits
log = logs.get_logger(__name__)
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
@@ -417,6 +417,19 @@ def run_enrich(db: Session) -> dict:
return {"enriched": n, **({"countries_coded": coded} if coded else {})}
def run_people_enrich(db: Session) -> dict:
"""Scheduler entry point of people_enrich (E30 S6): a batch of person bios (the most-credited
first) and a batch of portraits. Each half is a no-op — never an error — without its TMDB key /
without Plex."""
batch = settings.people_enrich_batch
if sysconfig.get_str(db, "tmdb_api_key"):
with TmdbClient(db) as tmdb:
bios = enrich_people_batch(db, tmdb, batch)
else:
bios = {"skipped": "tmdb_api_key not set"}
return {"bios": bios, "portraits": warm_portraits(db, batch)}
def resolve_library_guids(
db: Session,
entries: list[dict] | None,
@@ -627,14 +640,21 @@ def enrich_person(db: Session, person: PlexPerson) -> bool:
return True
# Lookups failing this many people in a row end a people batch: TMDB itself is degraded, and hammering
# it helps nobody. One person failing alone is skipped, so an id TMDB keeps refusing never stalls the rest.
_PEOPLE_FAILS_IN_A_ROW = 3
def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict:
"""Fetch up to `limit` linked, not-yet-enriched people (E30 S6), the most-credited first, so a
person page reads its bio from the row instead of waiting on TMDB. Each person is committed as it
lands: a page open during the run already sees it. A transient TmdbError ends the batch (TMDB is
degraded; hammering it helps nobody) and the rest wait for the next run."""
lands: a page open during the run already sees it. A failed lookup is skipped (left unstamped for the
next run); _PEOPLE_FAILS_IN_A_ROW of them in a row end the batch."""
ids = people_by_credits(db, "bio", limit)
done = 0
done = failed = in_a_row = 0
for pid in ids:
if in_a_row >= _PEOPLE_FAILS_IN_A_ROW:
break
person = db.get(PlexPerson, pid)
# A page open may have enriched this person since the select: skip, don't fetch twice.
if person is None or person.tmdb_enriched_at is not None:
@@ -643,13 +663,14 @@ def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict:
_fetch_person_into(tmdb, person)
except TmdbError as exc:
db.rollback()
failed, in_a_row = failed + 1, in_a_row + 1
log.warning(
"the TMDB person batch stopped on a failed lookup",
"a TMDB person lookup in the batch failed",
extra=logs.event(
"plex.tmdb.people_batch_failed", person_id=pid, reason=type(exc).__name__
),
)
return {"enriched": done, "failed": 1}
continue
db.commit()
done += 1
return {"enriched": done}
done, in_a_row = done + 1, 0
return {"enriched": done, **({"failed": failed} if failed else {})}