Skip to content

Commit 0f0fef6

Browse files
committed
fix(mdblist): stop importing show rollup entries as watch events
1 parent 04fd6d3 commit 0f0fef6

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

backend/routers/mdblist.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,12 @@ async def _import_watched(
401401
existing = {row[0] for row in existing_result.all()}
402402
changed: set[int] = set()
403403

404-
for kind in ("movies", "shows", "episodes"):
404+
# MDBList's /sync/watched "shows" entries are rollup wrappers (a show's
405+
# own last_watched_at just mirrors its most recently watched episode) —
406+
# they carry no per-episode data of their own. Importing them as watch
407+
# events creates a spurious series-level WatchEvent alongside the real
408+
# episode-level one for every watched show.
409+
for kind in ("movies", "episodes"):
405410
for entry in payload.get(kind, []):
406411
try:
407412
async with db.begin_nested():
@@ -429,7 +434,7 @@ async def _import_watched(
429434
logger.warning("Error importing MDBList %s watch item: %s", kind, exc)
430435
stats["errors"] += 1
431436

432-
stats["skipped"] += len(payload.get("seasons", []))
437+
stats["skipped"] += len(payload.get("seasons", [])) + len(payload.get("shows", []))
433438
return changed
434439

435440

backend/tests/test_mdblist.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,5 +403,78 @@ def test_merge_show_entries_combines_show_rating_with_season_removal(self) -> No
403403
)
404404

405405

406+
class _WatchedFakeSession:
407+
"""Fakes just enough of AsyncSession for _import_watched: an empty
408+
existing-watch-events query, plus recording every WatchEvent added."""
409+
410+
def __init__(self) -> None:
411+
self.added: list = []
412+
413+
async def execute(self, statement):
414+
return SimpleNamespace(all=lambda: [])
415+
416+
def begin_nested(self):
417+
return self
418+
419+
async def __aenter__(self):
420+
return self
421+
422+
async def __aexit__(self, exc_type, exc, traceback):
423+
return False
424+
425+
def add(self, obj):
426+
self.added.append(obj)
427+
428+
429+
class ImportWatchedSkipsShowRollupTests(unittest.IsolatedAsyncioTestCase):
430+
async def test_shows_entries_are_not_imported_as_watch_events(self) -> None:
431+
"""Regression test: MDBList's /sync/watched "shows" entries are rollup
432+
wrappers whose watched_at just mirrors the show's most recently
433+
watched episode — they carry no per-episode data of their own.
434+
Importing them as standalone watch events created a bogus
435+
series-level WatchEvent for every watched show, alongside the real
436+
episode-level one, and could collide with an unrelated movie that
437+
happens to share the same TMDB id (movies and shows are separate
438+
TMDB id namespaces)."""
439+
from routers.mdblist import _import_watched
440+
441+
seen_kinds: list[str] = []
442+
443+
async def fake_resolve_media(db, kind, entry, api_key, external_cache):
444+
seen_kinds.append(kind)
445+
if kind == "movies":
446+
return SimpleNamespace(id=1)
447+
if kind == "episodes":
448+
return SimpleNamespace(id=2)
449+
return SimpleNamespace(id=999) # would only happen on regression
450+
451+
payload = {
452+
"movies": [{"ids": {"tmdb": 100}, "watched_at": "2026-08-01T00:00:00Z"}],
453+
"shows": [
454+
{"ids": {"tmdb": 32726}, "last_watched_at": "2026-08-01T17:37:45Z"}
455+
],
456+
"episodes": [
457+
{
458+
"episode": {"season": 12, "number": 1},
459+
"show": {"ids": {"tmdb": 32726}},
460+
"last_watched_at": "2026-08-01T17:37:45Z",
461+
}
462+
],
463+
}
464+
stats = {"watched": 0, "skipped": 0, "errors": 0}
465+
db = _WatchedFakeSession()
466+
467+
with patch("routers.mdblist._resolve_media", side_effect=fake_resolve_media):
468+
changed = await _import_watched(
469+
db, user_id=35, payload=payload, api_key=None, external_cache={}, stats=stats
470+
)
471+
472+
self.assertEqual(seen_kinds, ["movies", "episodes"])
473+
self.assertEqual({obj.media_id for obj in db.added}, {1, 2})
474+
self.assertEqual(stats["watched"], 2)
475+
self.assertEqual(stats["skipped"], 1)
476+
self.assertEqual(changed, {1, 2})
477+
478+
406479
if __name__ == "__main__":
407480
unittest.main()

0 commit comments

Comments
 (0)