video enrichment: add a GLOBAL 'Retry all failed' (all workers, all kinds)

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.
video
BoulderBadgeDad 1 week ago
parent 49222dd0b8
commit bb1074cc60

@ -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/<service>/retry", methods=["POST"])
def video_enrichment_retry(service):
from . import get_video_db

@ -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

@ -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()

@ -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

@ -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 @@
'<button data-em-priority="movie">Movies</button>' +
'<button data-em-priority="show">Shows</button>' +
'<button data-em-priority="" class="em-global-auto">Auto</button></div></div>' +
'<div class="em-topbar-actions"><button class="em-icon-btn" data-em-refresh title="Refresh">⟳</button>' +
'<div class="em-topbar-actions">' +
'<button class="em-icon-btn em-retry-global" data-em-retry-all-global title="Re-queue every failed item across ALL workers">↻ Retry all failed</button>' +
'<button class="em-icon-btn" data-em-refresh title="Refresh">⟳</button>' +
'<button class="em-icon-btn em-icon-btn--close" data-em-close title="Close">&times;</button></div></div>' +
'<div class="em-body"><div class="em-rail" id="vem-rail"></div><div class="em-panel" id="vem-panel"></div></div></div>';
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'));

@ -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; }

Loading…
Cancel
Save