From bb1074cc601edc4e10f522bfac98ab324ecba710 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 20 Jun 2026 14:15:18 -0700 Subject: [PATCH] video enrichment: add a GLOBAL 'Retry all failed' (all workers, all kinds) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alongside the per-worker 'Retry all failed', the worker modal now has a topbar 'Retry all failed' that re-queues every failed/not_found item across ALL workers and kinds in one click — one-shot recovery after an API outage left lots errored. - db.retry_all_failed() derives the full service+kind set from the same _ENRICH / _BACKFILL maps the workers use (tmdb/tvdb + omdb + fanart/opensubtitles/trakt/ tvmaze/anilist/wikidata + ryd/sponsorblock/dearrow), loops enrichment_retry, returns the total re-queued. POST /api/video/enrichment/retry-all-failed. - Topbar button (amber, text) → calls it, toasts the count, refreshes the modal. DB test (resets across matcher + backfill + youtube service, deterministic count) + frontend wiring test. ruff + node --check clean. --- api/video/enrichment.py | 7 +++++ database/video_database.py | 17 ++++++++++++ tests/test_video_enrichment.py | 20 ++++++++++++++ tests/test_video_enrichment_manager.py | 9 +++++++ .../static/video/video-enrichment-manager.js | 26 +++++++++++++++++-- webui/static/video/video-side.css | 10 +++++++ 6 files changed, 87 insertions(+), 2 deletions(-) diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 64f323ac..548ad625 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -202,6 +202,13 @@ def register_routes(bp): res.update({"service": service, "kind": kind}) return jsonify(res) + @bp.route("/enrichment/retry-all-failed", methods=["POST"]) + def video_enrichment_retry_all_failed(): + """Global re-queue: reset every failed/not_found item across ALL workers and + kinds (one-click recovery after an outage). Returns the total re-queued.""" + from . import get_video_db + return jsonify({"success": True, "reset": get_video_db().retry_all_failed()}) + @bp.route("/enrichment//retry", methods=["POST"]) def video_enrichment_retry(service): from . import get_video_db diff --git a/database/video_database.py b/database/video_database.py index 4e308c3b..8e46c821 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1145,6 +1145,23 @@ class VideoDatabase: finally: conn.close() + def retry_all_failed(self) -> int: + """Re-queue every failed/not_found item across ALL enrichment services and + their kinds (the modal's GLOBAL 'Retry all failed'). Derives the service+kind + set from the same maps the workers use, so it stays in sync. Returns the + total number of items re-queued.""" + pairs = [(svc, k) for svc, kinds in _ENRICH.items() for k in kinds] # tmdb, tvdb + pairs += [("omdb", "movie"), ("omdb", "show")] # ratings (special-cased) + pairs += [(svc, k) for svc, kinds in _BACKFILL.items() for k in kinds] # fanart/trakt/… + pairs += [("ryd", "video"), ("sponsorblock", "video"), ("dearrow", "video")] # YouTube video stats + total = 0 + for svc, kind in pairs: + try: + total += self.enrichment_retry(svc, kind, scope="failed") + except Exception: + logger.exception("retry_all_failed: %s/%s failed", svc, kind) + return total + def requeue_shows_for_airtime(self) -> int: """One-time backfill: re-queue TVDB enrichment for shows that have a tvdb_id but no air time yet, so the worker re-fetches `airsTime`. Only diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 8d714477..4b0cadbe 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -1311,3 +1311,23 @@ def test_top_owned_genres_orders_by_count(db): g = db.top_owned_genres("movie", server_source="plex", limit=5) assert g[0] == "Action" # owned twice → first assert "Drama" in g and "Comedy" not in g # Comedy movie isn't owned + + +def test_retry_all_failed_requeues_across_all_services(db): + # Global retry: one failed item in a matcher (tmdb), a backfill (trakt) and a + # YouTube video service (dearrow) — all re-queued in one call. imdb_rating is set + # so OMDb's "unrated" re-queue doesn't add to the count (keeps it deterministic). + conn = db._get_connection() + conn.execute("INSERT INTO movies (server_source, server_id, title, tmdb_match_status, imdb_rating) " + "VALUES ('plex','m1','M','not_found', 7.0)") + conn.execute("INSERT INTO shows (server_source, server_id, title, imdb_id, trakt_status, imdb_rating) " + "VALUES ('plex','s1','S','tt1','error', 8.0)") + conn.execute("INSERT INTO youtube_video_stats (youtube_id, dearrow_status) VALUES ('v1','not_found')") + conn.commit(); conn.close() + + assert db.retry_all_failed() == 3 + conn = db._get_connection() + assert conn.execute("SELECT tmdb_match_status FROM movies WHERE server_id='m1'").fetchone()[0] is None + assert conn.execute("SELECT trakt_status FROM shows WHERE server_id='s1'").fetchone()[0] is None + assert conn.execute("SELECT dearrow_status FROM youtube_video_stats WHERE youtube_id='v1'").fetchone()[0] is None + conn.close() diff --git a/tests/test_video_enrichment_manager.py b/tests/test_video_enrichment_manager.py index 948ca150..b45adfd7 100644 --- a/tests/test_video_enrichment_manager.py +++ b/tests/test_video_enrichment_manager.py @@ -40,6 +40,15 @@ def test_priority_changes_only_from_top_tabs(): assert "function requeueFailed(" not in _JS +def test_global_retry_all_failed_button_wired(): + # a topbar button that re-queues failed items across ALL workers in one call + assert "data-em-retry-all-global" in _JS + assert "function retryAllFailedGlobal(" in _JS + assert "/api/video/enrichment/retry-all-failed" in _JS + # it's distinct from the per-worker retry (which targets one worker) + assert "retryAllFailedGlobal()" in _JS + + def test_retry_all_failed_covers_every_kind_of_the_worker(): body = _func("retryAllFailed") # iterate the worker's kinds (movie+show / show / video …), retry each diff --git a/webui/static/video/video-enrichment-manager.js b/webui/static/video/video-enrichment-manager.js index f64e60ea..fafed512 100644 --- a/webui/static/video/video-enrichment-manager.js +++ b/webui/static/video/video-enrichment-manager.js @@ -400,6 +400,24 @@ renderCards(); loadUnmatched().then(function () { renderControls(); renderList(); }); } + // GLOBAL "Retry all failed": re-queue every failed/not_found item across ALL + // workers + kinds in one call (one-click recovery after an outage). + function retryAllFailedGlobal() { + var btn = document.querySelector('[data-em-retry-all-global]'); + if (btn) btn.disabled = true; + fetch('/api/video/enrichment/retry-all-failed', { method: 'POST', headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + var n = (d && d.reset) || 0; + if (typeof showToast === 'function') { + showToast(n ? 'Re-queued ' + n + ' failed item' + (n === 1 ? '' : 's') + ' across all workers' + : 'Nothing failed to retry', n ? 'success' : 'info'); + } + return refreshAll(); + }) + .then(function () { renderRail(); selectWorker(state.selected); if (btn) btn.disabled = false; }) + .catch(function () { if (btn) btn.disabled = false; }); + } function togglePause() { var s = state.statuses[state.selected] || {}; if (!s.enabled) return; @@ -478,7 +496,9 @@ '' + '' + '' + - '
' + + '
' + + '' + + '' + '
' + '
'; overlay.addEventListener('click', onOverlayClick); @@ -496,9 +516,11 @@ var overlay = byId('vem-overlay'); if (e.target === overlay) { close(); return; } var t = e.target.closest('[data-em-select],[data-em-pause],[data-em-kind],[data-em-retry-all],' + - '[data-em-retry-item],[data-em-page],[data-em-refresh],[data-em-close],[data-em-priority]'); + '[data-em-retry-item],[data-em-page],[data-em-refresh],[data-em-close],[data-em-priority],' + + '[data-em-retry-all-global]'); if (!t) return; if (t.hasAttribute('data-em-close')) close(); + else if (t.hasAttribute('data-em-retry-all-global')) retryAllFailedGlobal(); else if (t.hasAttribute('data-em-priority')) setPriority(t.getAttribute('data-em-priority')); else if (t.hasAttribute('data-em-refresh')) { refreshAll().then(renderRail); selectWorker(state.selected); } else if (t.hasAttribute('data-em-select')) selectWorker(t.getAttribute('data-em-select')); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index ccd8e5fb..8ac2b217 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3507,3 +3507,13 @@ body[data-side="video"] #soulsync-toggle { display: none; } /* Automation Hub panes that are music-specific — emptied on the video side for now. */ .vauto-hub-soon { padding: 36px 20px; text-align: center; font-size: 13.5px; font-weight: 600; color: rgba(255, 255, 255, 0.38); } + +/* Global "Retry all failed" — a text button in the worker modal topbar (overrides + the round .em-icon-btn sizing + its rotate-on-hover; amber = retry/failed). */ +.em-retry-global { + width: auto; height: 32px; padding: 0 13px; border-radius: 999px; + gap: 6px; font-size: 12px; font-weight: 800; white-space: nowrap; + color: #fbcd7a; background: rgba(245, 158, 11, 0.14); +} +.em-retry-global:hover { transform: none; background: rgba(245, 158, 11, 0.24); } +.em-retry-global:disabled { opacity: 0.55; cursor: default; }