YouTube enricher: coverage-aware retry + show in Manage Workers modal

- Fixes 'stuck idle': a channel marked enriched with FEW dates (proxies were
  down) was locked out for 24h, so the improved yt-dlp fallback never re-ran.
  channel_dates_enriched_recently now retries in 15 min when the last run got
  <15 dates, and skips 24h only after a good run. So the catalog actually fills
  in once a source works.
- The YouTube Dates worker now appears in the Manage Workers modal: added to
  WORKERS (red ▶), youtube-aware rail subtitle, and a dedicated simple panel
  (status + what-it-does + channels-enriched / dates-cached / queued counts) —
  no per-kind match queue. Pause/resume works; polls every 3s like the rail.
This commit is contained in:
BoulderBadgeDad 2026-06-17 11:37:26 -07:00
parent 9524a40615
commit 581735ac7d
3 changed files with 55 additions and 8 deletions

View file

@ -2055,13 +2055,17 @@ class VideoDatabase:
conn.close()
def channel_dates_enriched_recently(self, channel_id, within_hours=24) -> bool:
"""True if the channel was date-enriched within the window (don't re-sweep)."""
"""True if the channel was date-enriched within the window (don't re-sweep).
Coverage-aware: a run that produced FEW dates (proxies were down) retries
soon instead of being locked out for the full window so the catalog
actually fills in once a source works."""
if not channel_id:
return False
conn = self._get_connection()
try:
row = conn.execute("SELECT enriched_at FROM youtube_channel_enrichment WHERE channel_id=?",
(str(channel_id),)).fetchone()
row = conn.execute(
"SELECT enriched_at, date_count FROM youtube_channel_enrichment WHERE channel_id=?",
(str(channel_id),)).fetchone()
if not row or not row["enriched_at"]:
return False
try:
@ -2069,7 +2073,9 @@ class VideoDatabase:
except (ValueError, TypeError):
return False
now = datetime.now(timezone.utc).replace(tzinfo=None) # naive UTC, matches CURRENT_TIMESTAMP
return (now - when) < timedelta(hours=within_hours)
# Good coverage → skip for the full window; thin result → retry in 15 min.
window = within_hours if (row["date_count"] or 0) >= 15 else 0.25
return (now - when) < timedelta(hours=window)
finally:
conn.close()

View file

@ -18,6 +18,8 @@
{ id: 'tmdb', name: 'TMDB', color: '#38bdf8', rgb: '56, 189, 248', kinds: ['movie', 'show'] },
{ id: 'tvdb', name: 'TVDB', color: '#a855f7', rgb: '168, 85, 247', kinds: ['show'] },
{ id: 'omdb', name: 'OMDb', color: '#f5c518', rgb: '245, 197, 24', kinds: ['movie', 'show'] },
// The YouTube date enricher — no per-kind match queue; its own simple panel.
{ id: 'youtube', name: 'YouTube Dates', color: '#ff3b3b', rgb: '255, 59, 59', kinds: [], glyph: '▶' },
];
function workerDef(id) {
@ -64,8 +66,13 @@
}
return t ? Math.round(m / t * 100) : 0;
}
function railSub(s) {
function railSub(s, id) {
if (!s || !s.enabled) return 'Not configured';
if (id === 'youtube') {
if (s.running && s.current_item && s.current_item.name) return s.current_item.name;
if (s.queued) return s.queued + ' queued';
return (s.dates_cached || 0) + ' dates cached';
}
if (s.idle) return 'All matched';
if (s.running && !s.paused && s.current_item && s.current_item.name) return s.current_item.name;
return (s.stats ? (s.stats.pending || 0) : 0) + ' pending';
@ -119,11 +126,11 @@
'<span class="em-rail-cov"><span class="em-rail-cov-fill" style="width:' + pct + '%"></span></span>';
var icon = LOGOS[w.id]
? '<img class="vem-logo vem-logo--' + w.id + '" src="' + LOGOS[w.id] + '" alt="">'
: '<span class="vem-glyph" style="color:' + w.color + '"></span>';
: '<span class="vem-glyph" style="color:' + w.color + '">' + (w.glyph || '★') + '</span>';
return '<button class="em-worker-row" data-em-select="' + w.id + '" style="--row-accent: ' + w.rgb + '">' +
'<span class="em-worker-icon">' + icon + '</span>' +
'<span class="em-worker-meta"><span class="em-worker-name">' + esc(w.name) + '</span>' +
'<span class="em-worker-sub">' + esc(railSub(s)) + '</span>' + cov + '</span>' +
'<span class="em-worker-sub">' + esc(railSub(s, w.id)) + '</span>' + cov + '</span>' +
'<span class="em-dot em-dot--' + info.cls + '" title="' + info.label + '"></span></button>';
}).join('');
WORKERS.forEach(function (w) {
@ -132,6 +139,25 @@
});
}
function renderYoutubePanel(panel) {
var s = state.statuses.youtube || {};
var info = statusInfo(s);
var prog = (s.progress && s.progress.channels) || {};
var current = (s.current_item && s.current_item.name)
? '<span class="em-ph-current">channel: ' + esc(s.current_item.name) + '</span>' : '';
panel.innerHTML =
'<div class="em-panel-header">' +
'<div class="em-ph-main"><span class="em-dot em-dot--' + info.cls + '"></span>' +
'<strong>YouTube Dates</strong><span class="em-ph-status">' + info.label + '</span>' + current + '</div>' +
'<button class="em-pause-btn" data-em-pause>' + (s.paused ? '▶ Resume' : '⏸ Pause') + '</button></div>' +
'<div class="vem-yt-about">Fetches real upload dates for the YouTube channels you follow, so the channel page can group videos into year-seasons. Runs in the background when you follow or open a channel — bulk via a no-key proxy, falling back to per-video; cached so it\'s a one-time pass per channel.</div>' +
'<div class="vem-yt-stats">' +
'<div class="vem-yt-stat"><span class="vem-yt-num">' + (prog.matched || 0) + '</span><span class="vem-yt-lbl">channels enriched</span></div>' +
'<div class="vem-yt-stat"><span class="vem-yt-num">' + (s.dates_cached || 0) + '</span><span class="vem-yt-lbl">dates cached</span></div>' +
'<div class="vem-yt-stat"><span class="vem-yt-num">' + (s.queued || 0) + '</span><span class="vem-yt-lbl">queued</span></div>' +
'</div>';
}
function renderPanel() {
var panel = byId('vem-panel');
if (!panel) return;
@ -139,6 +165,7 @@
var w = WORKERS.find(function (x) { return x.id === state.selected; }) || WORKERS[0];
panel.style.setProperty('--em-accent', w.color);
panel.style.setProperty('--em-accent-rgb', w.rgb);
if (state.selected === 'youtube') { renderYoutubePanel(panel); return; }
panel.innerHTML =
'<div class="em-panel-header" id="vem-panel-header"></div>' +
'<div class="em-section-label em-section-label--row"><span>Coverage</span>' +
@ -276,6 +303,7 @@
state.selected = id; state.breakdown = null; state.unmatched = null;
state.kind = defaultKind(id); state.page = 0; state.search = '';
renderRail(); renderPanel();
if (id === 'youtube') return; // date worker has no breakdown/unmatched
Promise.all([loadBreakdown(id), loadUnmatched()]).then(function () { renderPanel(); });
}
function switchKind(kind) {
@ -416,7 +444,11 @@
state.pollTimer = setInterval(function () {
if (!state.open) return;
getJSON('/api/video/enrichment/' + state.selected + '/status').then(function (d) {
if (d) { state.statuses[state.selected] = d; renderHeader(); renderRail(); }
if (d) {
state.statuses[state.selected] = d;
if (state.selected === 'youtube') renderPanel(); else renderHeader();
renderRail();
}
});
}, 3000);
}

View file

@ -2977,3 +2977,12 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vyt-skel-line { height: 11px; border-radius: 5px; width: 78px; }
.vyt-skel-line--sm { height: 9px; width: 52px; }
@media (prefers-reduced-motion: reduce) { .vyt-skel, .vsr-yt-loading::after { animation: none; } }
/* YouTube Dates worker — simple panel in the Manage Workers modal (no match queue) */
.vem-yt-about { margin: 4px 2px 20px; font-size: 13px; line-height: 1.6; color: rgba(255,255,255,0.6); }
.vem-yt-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.vem-yt-stat { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); border-radius: 12px;
padding: 18px 14px; text-align: center; }
.vem-yt-num { display: block; font-size: 28px; font-weight: 900; color: #fff; line-height: 1; }
.vem-yt-lbl { display: block; margin-top: 7px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
color: rgba(255,255,255,0.45); }