From 71cfa31f094b3bcb7e4f488f305c37cd89484c31 Mon Sep 17 00:00:00 2001 From: Jess Hallsworth Date: Thu, 10 Sep 2026 14:30:09 +0000 Subject: [PATCH] Fix false "Plex fallback" banner during the first scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit history_coverage is only written at the end of an ingest, but has_completion_data was reading from it. During the first scan the coverage table is empty while watch_event already holds tens of thousands of Tautulli rows, so the dashboard announced a fallback that had not happened and claimed the rejection component was disabled when it was not. The flag now comes from the events themselves — does any row carry a percent_complete — which is true the moment Tautulli rows land and false for Plex-only history. history_source falls back to the scan record when coverage is absent, so it reads "tautulli" mid-scan instead of null. Also: the banner named Plex as the source without checking, and now reports whichever source is actually active, and stays quiet while a scan is running since the counts are still moving. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra --- mediashelf/api.py | 2 +- mediashelf/queries.py | 27 +++++++++++++++++++++------ mediashelf/static/app.js | 11 ++++++++--- tests/test_rules_and_api.py | 25 +++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/mediashelf/api.py b/mediashelf/api.py index 246a7a5..08801d1 100644 --- a/mediashelf/api.py +++ b/mediashelf/api.py @@ -212,7 +212,7 @@ def sources(): return jsonify({ "plex": {"configured": c.plex_configured, "base_url": c.plex_base_url}, "tautulli": {"configured": c.tautulli_configured, "base_url": c.tautulli_base_url}, - "history_source": (cov[0]["source"] if cov else None), + "history_source": queries.active_history_source(d), "has_completion_data": queries.history_has_completion(d), "coverage": cov, "providers": [dict(r) for r in d.query("SELECT * FROM provider")], diff --git a/mediashelf/queries.py b/mediashelf/queries.py index 5f21310..f330134 100644 --- a/mediashelf/queries.py +++ b/mediashelf/queries.py @@ -34,11 +34,26 @@ def _score_ctx(db, cfg, has_completion_data: bool) -> scoring.ScoreContext: def history_has_completion(db) -> bool: - """True when the active history source records percent_complete (§4.11).""" - row = db.one( - "SELECT source FROM history_coverage ORDER BY event_count DESC LIMIT 1" - ) - return bool(row and row["source"] == "tautulli") + """True when the ingested history actually carries percent_complete (§4.11). + + Read from the events themselves, not from history_coverage: that row is only + written at the END of an ingest, so during the first scan the coverage table + is empty while watch_event is already full of Tautulli rows. Deriving the + flag from the table made the UI announce a Plex fallback that had not + happened, mid-scan, on every fresh database. + """ + return bool(db.scalar( + "SELECT 1 FROM watch_event WHERE percent_complete IS NOT NULL LIMIT 1")) + + +def active_history_source(db) -> str | None: + """Which source history came from — coverage if it exists, else the scan.""" + row = db.one("SELECT source FROM history_coverage ORDER BY event_count DESC LIMIT 1") + if row: + return row["source"] + row = db.one("SELECT history_source FROM scan WHERE history_source IS NOT NULL " + "ORDER BY id DESC LIMIT 1") + return row["history_source"] if row else None BASE_COLUMNS = """ @@ -273,7 +288,7 @@ def overview(db, cfg) -> dict: "episodes": s("SELECT COUNT(*) FROM episode WHERE status='present'"), "watch_events": s("SELECT COUNT(*) FROM watch_event"), "accounts": s("SELECT COUNT(*) FROM account"), - "history_source": cov["source"] if cov else None, + "history_source": active_history_source(db), "history_since": cov["earliest_event_at"] if cov else None, "history_until": cov["latest_event_at"] if cov else None, "has_completion_data": history_has_completion(db), diff --git a/mediashelf/static/app.js b/mediashelf/static/app.js index b11edda..cd463c7 100644 --- a/mediashelf/static/app.js +++ b/mediashelf/static/app.js @@ -19,6 +19,7 @@ const state = { views: [], lastPage: null, hasCompletion: true, + scanning: false, }; /* ── helpers ──────────────────────────────────────────────────────── */ @@ -162,9 +163,11 @@ async function loadDashboard() { ? 'Completion data available — the rejection component is active' : 'No completion data — running degraded, rejection component disabled'; } - if (!ov.has_completion_data && ov.watch_events > 0) { - banner('warn', 'Watch history has no completion data (Plex fallback). ' + - 'The score is running degraded: the “rejection” component is disabled.'); + // Only a finished scan can tell us this; mid-scan the counts are still moving. + if (!ov.has_completion_data && ov.watch_events > 0 && !state.scanning) { + banner('warn', `Watch history has no completion data (source: ` + + `${ov.history_source || 'unknown'}). The score is running degraded: ` + + `the “rejection” component is disabled.`); } // size by library @@ -727,6 +730,7 @@ $('#scan-now').addEventListener('click', async () => { async function pollScan() { const btn = $('#scan-now'); btn.disabled = true; + state.scanning = true; const tick = async () => { const s = await api('/scans/current'); if (s) { @@ -735,6 +739,7 @@ async function pollScan() { } else { btn.textContent = 'Scan now'; btn.disabled = false; + state.scanning = false; $('#banner-area').replaceChildren(); show(state.view); } diff --git a/tests/test_rules_and_api.py b/tests/test_rules_and_api.py index 9ce6933..bf8fba2 100644 --- a/tests/test_rules_and_api.py +++ b/tests/test_rules_and_api.py @@ -182,3 +182,28 @@ def test_bulk_keep(client, scanned): assert r.status_code == 200 assert r.get_json()["created"] == len(ids) assert client.get("/api/v1/keeps").get_json()["kept_items"] >= len(ids) + + +def test_completion_flag_comes_from_events_not_the_coverage_table(client, scanned): + """history_coverage is written at the END of an ingest. Reading the flag from + it made the UI announce a Plex fallback mid-scan on every fresh database.""" + from mediashelf import queries + + assert queries.history_has_completion(scanned) is True + + # simulate mid-scan: events present, coverage not yet written + scanned.execute("DELETE FROM history_coverage") + assert queries.history_has_completion(scanned) is True, \ + "flag went false with events already ingested" + assert queries.active_history_source(scanned) == "tautulli", \ + "source should fall back to the scan record when coverage is absent" + + ov = client.get("/api/v1/stats/overview").get_json() + assert ov["has_completion_data"] is True + assert ov["history_source"] == "tautulli" + + +def test_completion_flag_is_false_for_plex_only_history(scanned): + from mediashelf import queries + scanned.execute("UPDATE watch_event SET percent_complete = NULL") + assert queries.history_has_completion(scanned) is False