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.
This commit is contained in:
parent
49222dd0b8
commit
bb1074cc60
6 changed files with 87 additions and 2 deletions
|
|
@ -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">×</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…
Reference in a new issue