Story
As Adam-as-operator, I want the existing duplicate-episode-slot audit
(app/jobs/audit_watch_gaps.py) to run automatically on a schedule, instead
of only when someone manually invokes the CLI, so that TVMaze episode-id
reassignments get repaired within a day instead of silently corrupting a
show's watched status until a user happens to notice.
Context
Investigated a report: "House of the Dragon is off — 3.6 shows unwatched and
TBA, Schedule names it Faceless Man" plus a follow-up that the PWA Schedule
shows the episode unwatched while the show detail page shows it watched.
Root cause: a duplicate tv_episodes row for season 3 episode 6 — the same
bug class already documented as #169 (first caught on Firefly S1E12).
TVMaze reassigned the episode's id. sync_episodes() (app/services/tv_search.py:251)
matches incoming episodes by TVMaze id first, falling back to
(season, season_number) slot — and that slot fallback already handles the
common case cleanly (single reassignment onto an unambiguous existing row
merges in place, no duplicate). It only refuses to merge when the slot is
already ambiguous (2+ existing rows for that slot), which is a
deliberate, tested guard (test_ambiguous_slot_is_left_alone,
tests/integration/router_tv_reassignment_test.py:94) — guessing which of
two existing rows to merge into risks silently attaching history to the
wrong row. That's correct and shouldn't change.
The gap is downstream: reconciling an already-ambiguous slot is explicitly
left to audit_watch_gaps.py --fix, which does it safely (oldest row is the
keeper, watch marks are moved not lost, never invents a watch — see
_repair_slot()). But that job has no scheduled invocation — it's a
CLI-only tool (python -m app.jobs.audit_watch_gaps), run by hand. Once a
slot goes ambiguous, it stays broken — visibly disagreeing between the
Schedule (get_schedule(), app/router/v1/router_tv.py:421, which requires
an unwatched anti-join + non-null airdate) and the show detail page
(get_all_episodes(), app/router/v1/router_tv.py:203, which returns every
row) — until someone stumbles on the symptom and remembers the CLI exists.
druthers-infra already has the exact pattern to copy: cron/refresh-tv.sh
runs nightly at 04:15 UTC from hephaestus against Neon prod, using the
released GHCR image (see druthers-infra/docs/CRONS.md). A new
cron/audit-watch-gaps.sh following that same shape — pull image, run
python -m app.jobs.audit_watch_gaps --fix inside the container — slotted in
after the nightly TV refresh, would give this the same safety net.
Cross-references: druthers-infra (new cron script + crontab entry +
CRONS.md update), druthers-api (job already exists, no code change
expected unless output needs to be more log-scrapeable for step 3 below).
Acceptance Criteria
Estimate
- Recommended model: Opus 4.8 — touches a job that writes prod data
unattended on a schedule, plus cross-repo infra wiring (cron script,
crontab, image/env pattern) with real prod impact if the cadence or
--fix behavior is wrong.
- Human effort: M — one decision on cadence/pairing with the existing
refresh-tv slot, and a manual verification pass on hephaestus (crontab
edit, log check) that the AI can't do unassisted.
Notes for Implementation (Optional)
Mirror druthers-infra/cron/refresh-tv.sh almost exactly: same
ENV_FILE/IMAGE/DOCKER variable pattern, same pull-with-fallback, same
ghcr.io/aleonard9/druthers-api:latest image, swapping the module to
python -m app.jobs.audit_watch_gaps --fix. Schedule it after the 04:15 UTC
refresh-tv.sh slot so a newly-synced duplicate gets caught the same night
it's created rather than a day later.
Story
As Adam-as-operator, I want the existing duplicate-episode-slot audit
(
app/jobs/audit_watch_gaps.py) to run automatically on a schedule, insteadof only when someone manually invokes the CLI, so that TVMaze episode-id
reassignments get repaired within a day instead of silently corrupting a
show's watched status until a user happens to notice.
Context
Investigated a report: "House of the Dragon is off — 3.6 shows unwatched and
TBA, Schedule names it Faceless Man" plus a follow-up that the PWA Schedule
shows the episode unwatched while the show detail page shows it watched.
Root cause: a duplicate
tv_episodesrow for season 3 episode 6 — the samebug class already documented as #169 (first caught on Firefly S1E12).
TVMaze reassigned the episode's id.
sync_episodes()(app/services/tv_search.py:251)matches incoming episodes by TVMaze id first, falling back to
(season, season_number)slot — and that slot fallback already handles thecommon case cleanly (single reassignment onto an unambiguous existing row
merges in place, no duplicate). It only refuses to merge when the slot is
already ambiguous (2+ existing rows for that slot), which is a
deliberate, tested guard (
test_ambiguous_slot_is_left_alone,tests/integration/router_tv_reassignment_test.py:94) — guessing which oftwo existing rows to merge into risks silently attaching history to the
wrong row. That's correct and shouldn't change.
The gap is downstream: reconciling an already-ambiguous slot is explicitly
left to
audit_watch_gaps.py --fix, which does it safely (oldest row is thekeeper, watch marks are moved not lost, never invents a watch — see
_repair_slot()). But that job has no scheduled invocation — it's aCLI-only tool (
python -m app.jobs.audit_watch_gaps), run by hand. Once aslot goes ambiguous, it stays broken — visibly disagreeing between the
Schedule (
get_schedule(),app/router/v1/router_tv.py:421, which requiresan unwatched anti-join + non-null airdate) and the show detail page
(
get_all_episodes(),app/router/v1/router_tv.py:203, which returns everyrow) — until someone stumbles on the symptom and remembers the CLI exists.
druthers-infraalready has the exact pattern to copy:cron/refresh-tv.shruns nightly at 04:15 UTC from hephaestus against Neon prod, using the
released GHCR image (see
druthers-infra/docs/CRONS.md). A newcron/audit-watch-gaps.shfollowing that same shape — pull image, runpython -m app.jobs.audit_watch_gaps --fixinside the container — slotted inafter the nightly TV refresh, would give this the same safety net.
Cross-references:
druthers-infra(new cron script + crontab entry +CRONS.mdupdate),druthers-api(job already exists, no code changeexpected unless output needs to be more log-scrapeable for step 3 below).
Acceptance Criteria
audit_watch_gaps --fixruns automatically on a schedule (proposed:nightly, after
refresh_tv, following the existingrefresh-tv.shcron pattern) against all users, not just one email.
log (
~/dev/druthers/cron/log/), consistent with howrefresh-tv.shalready logs.
auto-fixes) is still logged/reported somewhere reviewable — it doesn't
need alerting, just needs to not silently vanish.
druthers-infra/docs/CRONS.mddocuments the new job in the cronstable, same as the existing
refresh-tv.shandneon-backup.shrows.sync_episodes()still refuses toguess on an already-ambiguous slot (
test_ambiguous_slot_is_left_alonekeeps passing) — this issue is about running the existing repair
job more often, not changing ingest-time merge logic.
Estimate
unattended on a schedule, plus cross-repo infra wiring (cron script,
crontab, image/env pattern) with real prod impact if the cadence or
--fixbehavior is wrong.refresh-tv slot, and a manual verification pass on hephaestus (crontab
edit, log check) that the AI can't do unassisted.
Notes for Implementation (Optional)
Mirror
druthers-infra/cron/refresh-tv.shalmost exactly: sameENV_FILE/IMAGE/DOCKERvariable pattern, same pull-with-fallback, sameghcr.io/aleonard9/druthers-api:latestimage, swapping the module topython -m app.jobs.audit_watch_gaps --fix. Schedule it after the 04:15 UTCrefresh-tv.shslot so a newly-synced duplicate gets caught the same nightit's created rather than a day later.