fix(plex): make cast enrichment insert conflict-safe

A full cold sync crashed and left plex_people empty (cast/people missing in
every detail view). enrich_row_people added PlexPerson rows via the ORM with no
ON CONFLICT, and because SessionLocal is autoflush=False a person shared by two
items in the same enrich batch was queued twice -> the single batch commit
violated the unique index ix_plex_people_name and rolled the whole batch back.
Prod dodged it by building up incrementally; dev hits it on every reseed+sync.

Insert missing people via an immediately-executed pg_insert with
on_conflict_do_nothing (deduped within the cast by name, keeping the first
non-null thumb), mirroring app/sync/videos.py:_insert_stubs. The immediate
execute also lets a later row in the batch see the row via the existing-names
query and skip it; the conflict guard additionally covers the concurrent
info-page enrichment path.

Adds a DB-backed regression test: two items with overlapping cast now enrich
without a unique-violation, one row per name, and a prior-NULL headshot is
back-filled. Mutation-checked (reverting the fix reddens it).
This commit is contained in:
2026-08-09 00:09:22 +02:00
parent da0224490c
commit d49bf3bb42
2 changed files with 62 additions and 6 deletions
+45 -2
View File
@@ -4,9 +4,9 @@ lane (`db` fixture) with a faked PlexClient — skipped without Postgres.
"""
from sqlalchemy import select
from app.plex import sync
from app.plex import people, sync
from app.plex.client import PlexError
from app.models import PlexLibrary
from app.models import PlexItem, PlexLibrary, PlexPerson
class _FakePlex:
@@ -42,3 +42,46 @@ def test_late_section_failure_keeps_earlier_sections(db, monkeypatch):
# Section 1's library survived the section-2 failure (per-section commit); section 2 rolled back.
keys = db.scalars(select(PlexLibrary.plex_key).order_by(PlexLibrary.plex_key)).all()
assert keys == ["1"]
class _FakeMetaPlex:
"""Fake PlexClient whose .metadata() returns the per-item rich metadata dict for a rating_key."""
def __init__(self, by_key):
self._by_key = by_key
def metadata(self, rating_key):
return self._by_key.get(rating_key)
def test_enrich_people_batch_dedupes_shared_cast(db):
"""A cold sync where one person appears across two items in the same batch must NOT crash on the
unique index — before the on_conflict fix, autoflush=False let both rows queue a duplicate
PlexPerson and the batch commit died, leaving plex_people empty. Also covers the prior-NULL photo
back-fill (m1 has no thumb, m2 supplies one for the same person)."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie")
db.add(lib)
db.flush()
db.add_all(
[
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Movie One"),
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Movie Two"),
]
)
db.commit()
shared = "Christopher Plummer"
thumb = people.PERSON_IMG_HOST + "cp.jpg"
fake = _FakeMetaPlex(
{
"m1": {"Role": [{"tag": shared, "role": "Self"}, {"tag": "Alice Unique"}]},
"m2": {"Role": [{"tag": shared, "role": "Self", "thumb": thumb}, {"tag": "Bob Unique"}]},
}
)
n = sync._enrich_people_batch(db, fake)
assert n == 2 # both items enriched — no unique-violation rollback
rows = {p.name: p for p in db.query(PlexPerson).all()}
assert set(rows) == {shared, "Alice Unique", "Bob Unique"} # exactly one row per name
assert rows[shared].photo_thumb is not None # m2's photo back-filled m1's NULL