From 01c101d24a7d5105c85fa6f1fb2a737daee2500f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 21 Jun 2026 02:27:08 -0700 Subject: [PATCH] Video: live per-episode download tracking + auto-wishlist today's airings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Sonarr-parity features. 1) Per-episode live tracking. "Grab season" was headless (only a button label changed); episode rows had a status span that was never populated. Now every episode ROW shows its own live state — Searching → Downloading % → Downloaded / Failed — via epTrack() polling /downloads/status?id, matching the inline movie tracker. Grab season lights all target rows at once; manual + per-source auto grabs also light their row; reopening the modal resumes tracking in-flight episodes (resumeEpisodeTracking via /downloads/active + search_ctx match). Season batch grabs through the same payload as a manual grab (_pickAndGrab → sendGrab(buildGrabPayload)). 2) Auto-wishlist airing episodes. New daily automation (video_add_airing_episodes): reads the calendar for episodes airing TODAY for followed shows, skips owned ones, adds the rest to the wishlist (idempotent). Handler uses injected seams (calendar read + wishlist write) so it's unit-tested without a DB/server. Registered + action block + seeded as a daily system automation (01:00), owned_by=video. --- core/automation/blocks.py | 2 + core/automation/handlers/registration.py | 6 + .../handlers/video_auto_wishlist_airing.py | 89 ++++++++++++ core/automation_engine.py | 9 ++ tests/automation/test_handler_registration.py | 1 + tests/test_video_auto_wishlist_airing.py | 74 ++++++++++ tests/test_video_download_tracking.py | 27 ++++ webui/static/video/video-download-view.js | 131 ++++++++++++++++-- webui/static/video/video-side.css | 15 ++ 9 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 core/automation/handlers/video_auto_wishlist_airing.py create mode 100644 tests/test_video_auto_wishlist_airing.py diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 0d4f1afb..556594a4 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -235,6 +235,8 @@ ACTIONS: list[dict] = [ {"value": "full", "label": "Full (add + refresh)"}], "default": "incremental"} ]}, + {"type": "video_add_airing_episodes", "label": "Wishlist Today's Airings", "icon": "calendar", "scope": "video", + "description": "Sonarr-style: add every episode airing today (for shows you follow) to the wishlist, skipping ones you already own", "available": True}, ] diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 9aed539f..ac686c73 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -35,6 +35,7 @@ from core.automation.handlers.download_cleanup import ( ) from core.automation.handlers.run_script import auto_run_script from core.automation.handlers.search_and_download import auto_search_and_download +from core.automation.handlers.video_auto_wishlist_airing import auto_video_add_airing_episodes from core.automation.handlers.video_scan_library import ( auto_video_scan_library, auto_video_scan_server, auto_video_update_database, ) @@ -186,6 +187,11 @@ def register_all(deps: AutomationDeps) -> None: 'video_update_database', lambda config: auto_video_update_database(config, deps), ) + # Sonarr-style: wishlist every episode airing today (for followed shows). + engine.register_action_handler( + 'video_add_airing_episodes', + lambda config: auto_video_add_airing_episodes(config, deps), + ) # Progress + history callbacks: the engine invokes these around # each handler run. Lift the closures from diff --git a/core/automation/handlers/video_auto_wishlist_airing.py b/core/automation/handlers/video_auto_wishlist_airing.py new file mode 100644 index 00000000..bff0d434 --- /dev/null +++ b/core/automation/handlers/video_auto_wishlist_airing.py @@ -0,0 +1,89 @@ +"""Automation handler: ``video_add_airing_episodes`` action. + +Sonarr-style "monitor airings": add every episode airing TODAY — for the TV shows you +follow on the video watchlist — to the video WISHLIST, skipping ones you already own. +Runs on a daily schedule so the day's airings queue up to be grabbed automatically. + +Like the other video handlers it lives on the SHARED automation side (so it may import +``core.video`` / ``api.video`` — the isolation contract only forbids the reverse) and +owns its own progress reporting (``_manages_own_progress``). The calendar read + wishlist +write are injected seams, so the handler is a pure function: tests pass fakes and never +touch a DB or a media server; production lazily binds the real calls. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Callable, Dict, List, Optional + +from core.automation.deps import AutomationDeps + + +def _default_fetch_airing(today: str) -> List[Dict[str, Any]]: + """Production wiring: the calendar's episodes airing on ``today`` for followed shows.""" + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().calendar_upcoming( + today, today, server_source=resolve_video_server(), watchlist_only=True) + + +def _default_add_episodes(show_tmdb_id: Any, show_title: Any, episodes: List[Dict[str, Any]]) -> int: + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().add_episodes_to_wishlist( + show_tmdb_id, show_title, episodes, server_source=resolve_video_server()) + + +def auto_video_add_airing_episodes( + config: Dict[str, Any], + deps: AutomationDeps, + *, + fetch_airing: Optional[Callable[[str], List[Dict[str, Any]]]] = None, + add_episodes: Optional[Callable[[Any, Any, List[Dict[str, Any]]], int]] = None, + today_fn: Optional[Callable[[], str]] = None, +) -> Dict[str, Any]: + """Add today's airing (unowned, followed-show) episodes to the video wishlist. + + Returns ``{'status': 'completed', 'episodes_added': int, 'shows': int, ...}``.""" + fetch_airing = fetch_airing or _default_fetch_airing + add_episodes = add_episodes or _default_add_episodes + today_fn = today_fn or (lambda: date.today().isoformat()) + automation_id = config.get('_automation_id') + try: + today = today_fn() + deps.update_progress(automation_id, phase="Checking today's airings…", progress=25, + log_line='Reading the calendar for episodes airing today', log_type='info') + rows = fetch_airing(today) or [] + + # Group what to wish for by show: airing today, NOT already owned, with a + # real season/episode. add_episodes_to_wishlist is idempotent, so re-runs + # never duplicate. + by_show: Dict[tuple, List[Dict[str, Any]]] = {} + for r in rows: + if r.get('has_file'): + continue + tid = r.get('show_tmdb_id') + if not tid or r.get('season_number') is None or r.get('episode_number') is None: + continue + by_show.setdefault((tid, r.get('show_title')), []).append({ + 'season_number': r.get('season_number'), + 'episode_number': r.get('episode_number'), + 'title': r.get('title'), + 'air_date': r.get('air_date'), + }) + + added = 0 + for (tid, title), eps in by_show.items(): + added += int(add_episodes(tid, title, eps) or 0) + shows = len(by_show) + + deps.update_progress( + automation_id, status='finished', progress=100, phase='Complete', + log_line=('Added %d airing episode(s) across %d show(s) to the wishlist' + % (added, shows)) if added else 'No new airing episodes to wishlist today', + log_type='success') + return {'status': 'completed', 'episodes_added': added, 'shows': shows, + '_manages_own_progress': True} + except Exception as e: # noqa: BLE001 + deps.update_progress(automation_id, status='error', phase='Error', log_line=str(e), log_type='error') + return {'status': 'error', 'error': str(e), '_manages_own_progress': True} diff --git a/core/automation_engine.py b/core/automation_engine.py index a599c8d0..f0b90f4a 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -170,6 +170,15 @@ SYSTEM_AUTOMATIONS = [ 'action_config': {'mode': 'incremental'}, 'owned_by': 'video', }, + # Sonarr-style: each day, wishlist every episode airing today for the shows you + # follow (skipping ones already owned) so they queue up to be grabbed. + { + 'name': 'Auto-Wishlist Episodes Airing Today', + 'trigger_type': 'daily_time', + 'trigger_config': {'time': '01:00'}, + 'action_type': 'video_add_airing_episodes', + 'owned_by': 'video', + }, ] diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index ef18334c..2cb2f9d2 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -57,6 +57,7 @@ EXPECTED_ACTION_NAMES = frozenset({ 'video_scan_library', 'video_scan_server', 'video_update_database', + 'video_add_airing_episodes', }) # Action names that MUST register a guard (duplicate-run prevention). diff --git a/tests/test_video_auto_wishlist_airing.py b/tests/test_video_auto_wishlist_airing.py new file mode 100644 index 00000000..e32ed54c --- /dev/null +++ b/tests/test_video_auto_wishlist_airing.py @@ -0,0 +1,74 @@ +"""Sonarr-style 'wishlist today's airings' automation handler — pure logic with the +calendar read + wishlist write injected, so it runs without a DB or media server.""" + +from __future__ import annotations + +from core.automation.handlers.video_auto_wishlist_airing import auto_video_add_airing_episodes + + +class _Deps: + def __init__(self): + self.progress = [] + + def update_progress(self, automation_id, **kw): + self.progress.append(kw) + + +def _row(tid, title, s, e, owned=False): + return {"show_tmdb_id": tid, "show_title": title, "season_number": s, + "episode_number": e, "title": "Ep", "air_date": "2026-06-21", "has_file": owned} + + +def test_adds_unowned_airings_grouped_by_show(): + rows = [ + _row(1, "Widows Bay", 1, 1), + _row(1, "Widows Bay", 1, 2), + _row(2, "Another Show", 3, 5), + _row(1, "Widows Bay", 1, 3, owned=True), # already owned → skipped + {"show_title": "No id", "season_number": 1, "episode_number": 1}, # no tmdb id → skipped + ] + added = [] + + def add(tid, title, eps): + added.append((tid, title, len(eps))) + return len(eps) + + res = auto_video_add_airing_episodes( + {"_automation_id": "a1"}, _Deps(), + fetch_airing=lambda today: rows, add_episodes=add, today_fn=lambda: "2026-06-21") + + assert res["status"] == "completed" + assert res["episodes_added"] == 3 # 2 of Widows Bay + 1 of Another Show + assert res["shows"] == 2 + assert (1, "Widows Bay", 2) in added and (2, "Another Show", 1) in added + + +def test_queries_the_calendar_for_today(): + seen = {} + + def fetch(today): + seen["today"] = today + return [] + + auto_video_add_airing_episodes({"_automation_id": "a"}, _Deps(), + fetch_airing=fetch, add_episodes=lambda *a: 0, + today_fn=lambda: "2026-06-21") + assert seen["today"] == "2026-06-21" # start == end == today + + +def test_nothing_airing_is_a_clean_noop(): + res = auto_video_add_airing_episodes({"_automation_id": "a"}, _Deps(), + fetch_airing=lambda t: [], add_episodes=lambda *a: 0, + today_fn=lambda: "2026-06-21") + assert res["status"] == "completed" and res["episodes_added"] == 0 + + +def test_error_is_caught_and_reported(): + def boom(today): + raise RuntimeError("calendar down") + + deps = _Deps() + res = auto_video_add_airing_episodes({"_automation_id": "a"}, deps, + fetch_airing=boom, add_episodes=lambda *a: 0) + assert res["status"] == "error" and "calendar down" in res["error"] + assert any(p.get("status") == "error" for p in deps.progress) diff --git a/tests/test_video_download_tracking.py b/tests/test_video_download_tracking.py index 864e1f33..8f09a804 100644 --- a/tests/test_video_download_tracking.py +++ b/tests/test_video_download_tracking.py @@ -91,6 +91,33 @@ def test_modal_resumes_active_download_on_reopen(): assert '.vdl-active' in _CSS +# --- per-episode live tracking (TV parity with the movie inline tracker) --- + +def test_episode_rows_get_live_download_status(): + # each episode ROW shows its own Searching → Downloading % → Downloaded status + assert 'function epTrack(' in _VIEW and 'function epStatusRender(' in _VIEW + assert "/api/video/downloads/status?id=" in _VIEW + assert 'data-vdl-ep-status' in _VIEW + assert 'vdl-ep--dl-active' in _VIEW and 'vdl-ep--dl-done' in _VIEW and 'vdl-ep--dl-fail' in _VIEW + assert '.vdl-ep-dl' in _CSS and '.vdl-ep-dl-bar' in _CSS + + +def test_season_grab_lights_rows_and_resumes_on_reopen(): + # season grab gives immediate per-row feedback (not headless), and reopening the + # modal resumes tracking in-flight episodes + assert 'function epSearching(' in _VIEW + assert 'eps.forEach(function (en) { epSearching(' in _VIEW # rows light up at once + assert 'function resumeEpisodeTracking(' in _VIEW + assert "/api/video/downloads/active" in _VIEW + assert 'resumeEpisodeTracking(container, st)' in _VIEW # called when rows build + + +def test_episode_grab_uses_the_shared_single_grab_path(): + # the season batch grabs via the same payload as a manual grab (can't diverge) + assert 'function _pickAndGrab(' in _VIEW + assert 'sendGrab(buildGrabPayload(panel, best))' in _VIEW + + # --- navigation plumbing -------------------------------------------------- def test_video_side_handles_navigate_event(): diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index db525571..cf19c007 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -448,6 +448,8 @@ if (res && res.ok) { toast('Sent to Downloads', 'success'); beginTracking(card, res.id); // selected card → live tracker + Track button + var _ep = panel.closest && panel.closest('.vdl-ep'); + if (_ep) epTrack(_ep, res.id); // also light the collapsed episode row document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { btn.disabled = false; btn.classList.remove('vdl-res-grab--busy'); @@ -488,6 +490,8 @@ if (statusEl) { statusEl.textContent = 'Sent'; statusEl.className = 'vdl-src-status vdl-src-status--done'; } toast('Sent to Downloads', 'success'); beginTracking(card, res.id); // chosen card → live tracker + Track button + var _ep = panel.closest && panel.closest('.vdl-ep'); + if (_ep) epTrack(_ep, res.id); // also light the collapsed episode row document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { if (gbtn) { gbtn.disabled = false; gbtn.classList.remove('vdl-res-grab--busy'); } @@ -712,6 +716,7 @@ if (meta) meta.textContent = eps.length + ' eps' + (missing ? ' · ' + missing + ' missing' : ''); card.setAttribute('data-loaded', '1'); syncSeason(container, sn); + resumeEpisodeTracking(container, st); // light up any in-flight episode rows } function fetchSeason(container, card, sn, st) { @@ -838,12 +843,83 @@ panel.innerHTML = '
' + srcs.map(function (s) { return srcBlockHTML(s, true); }).join('') + '
'; } + // ── per-episode live tracking ────────────────────────────────────────────── + // Every episode ROW carries its own live download status (Searching → Downloading + // % → Downloaded / Failed), so season grabs aren't headless and the modal shows + // exactly what each episode is doing — matching the inline movie tracker. + function _epEl(container, sn, en) { + return container.querySelector('.vdl-ep[data-vdl-ep="' + sn + '_' + en + '"]'); + } + + function epStatusRender(epEl, dl) { + var stEl = epEl.querySelector('[data-vdl-ep-status]'); if (!stEl) return; + var st = dl.status, pct = Math.max(0, Math.min(100, dl.progress || 0)); + if (st === 'completed') pct = 100; + epEl.classList.remove('vdl-ep--dl-active', 'vdl-ep--dl-done', 'vdl-ep--dl-fail'); + if (st === 'completed') { + epEl.classList.add('vdl-ep--dl-done'); + stEl.innerHTML = '✓ Downloaded'; + } else if (st === 'failed' || st === 'cancelled') { + epEl.classList.add('vdl-ep--dl-fail'); + stEl.innerHTML = '✗ ' + esc(TRACK_LABEL[st] || 'Failed') + ''; + } else { + epEl.classList.add('vdl-ep--dl-active'); + var label = st === 'downloading' ? ('Downloading ' + pct + '%') + : (st === 'searching' ? 'Searching…' : (TRACK_LABEL[st] || 'Queued')); + stEl.innerHTML = '' + + '' + esc(label) + ''; + } + } + + function epSearching(epEl) { + if (!epEl) return; + epEl.classList.remove('vdl-ep--dl-done', 'vdl-ep--dl-fail'); + epEl.classList.add('vdl-ep--dl-active'); + var s = epEl.querySelector('[data-vdl-ep-status]'); + if (s) s.innerHTML = 'Searching…'; + } + function epNoRelease(epEl) { + if (!epEl) return; + epEl.classList.remove('vdl-ep--dl-active'); + var s = epEl.querySelector('[data-vdl-ep-status]'); + if (s) s.innerHTML = 'No release found'; + } + + // Poll one episode's download by id and paint its row until it finishes. Robust to + // the modal closing (stops) and to a newer grab on the same row (id guard). + function epTrack(epEl, dlId) { + if (!epEl || dlId == null) return; + if (epEl._eptimer) { clearTimeout(epEl._eptimer); epEl._eptimer = null; } + epEl._epdl = dlId; + (function tick() { + if (!epEl.isConnected || epEl._epdl !== dlId) return; + getJSON('/api/video/downloads/status?id=' + encodeURIComponent(dlId)).then(function (d) { + if (!epEl.isConnected || epEl._epdl !== dlId) return; + var dl = d && d.download; + if (!dl) { epEl._eptimer = setTimeout(tick, 2200); return; } + epStatusRender(epEl, dl); + if (!(dl.status in TRACK_DONE)) epEl._eptimer = setTimeout(tick, 2000); + }); + })(); + } + + // Pick the best accepted+grabbable hit in a results panel and grab it. Returns + // {ok, id} — the id is what the episode row tracks. (Same payload as a manual grab.) + function _pickAndGrab(panel) { + var rows = (panel && panel._rows) || []; + var best = null; + for (var i = 0; i < rows.length; i++) { + if (rows[i].accepted && rows[i].username) { best = rows[i]; break; } + } + if (!best) return Promise.resolve({ ok: false }); + return sendGrab(buildGrabPayload(panel, best)).then(function (res) { + return (res && res.ok) ? { ok: true, id: res.id } : { ok: false }; + }); + } + // ── Grab whole season (episode level) ────────────────────────────────────── - // We DON'T grab a multi-file pack; we run the per-episode auto-grab for every - // MISSING episode, throttled, reusing searchInto + _autoPick — so each episode - // takes the exact same path a manual per-episode Auto would. A hidden scratch - // holds the headless result panels; the grabs surface on the Downloads page. - // (Pack-folder grabbing can come later; this covers the common case.) + // Run the per-episode auto-grab for every MISSING episode, throttled — each takes + // the same path a manual per-episode Auto would, and each ROW shows live status. function ensureScratch(container) { var s = container.querySelector('[data-vdl-grab-scratch]'); if (!s) { @@ -856,15 +932,27 @@ } function autoGrabEpisode(container, st, sn, en, src) { - // Resolves once the SEARCH settles (the grab POST fires inside _autoPick); - // throttling the searches is what protects slskd from a burst. + // Resolves once the search SETTLES (throttle the searches, not the grabs); the + // row then tracks the live download itself. return new Promise(function (resolve) { + var epEl = _epEl(container, sn, en); + epSearching(epEl); var panel = document.createElement('div'); panel.className = 'vdl-results vdl-res-noanim'; ensureScratch(container).appendChild(panel); searchInto(container, panel, { scope: 'episode', title: st.title, season: sn, episode: en, source: src }, - [], function () { _autoPick(panel, null); resolve(); }); + [], function () { + _pickAndGrab(panel).then(function (r) { + if (r.ok) { + epTrack(epEl, r.id); + document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); + } else { + epNoRelease(epEl); + } + resolve(r); + }); + }); }); } @@ -877,17 +965,20 @@ } eps.sort(function (a, b) { return a - b; }); if (!eps.length) { toast('No missing episodes in this season', 'info'); return; } + // make sure the season is open so the user sees the rows light up + var card = container.querySelector('.vdl-season[data-vdl-season="' + sn + '"]'); + if (card) card.classList.add('vdl-season--open'); + eps.forEach(function (en) { epSearching(_epEl(container, sn, en)); }); // immediate feedback var btn = container.querySelector('[data-vdl-season-grab="' + sn + '"]'); - if (btn) { btn.disabled = true; btn.textContent = 'Grabbing 0/' + eps.length; } - toast('Auto-grabbing ' + eps.length + ' missing episode' + (eps.length > 1 ? 's' : '') + ' — watch Downloads', 'info'); + if (btn) { btn.disabled = true; btn.textContent = 'Grabbing…'; } + toast('Grabbing ' + eps.length + ' episode' + (eps.length > 1 ? 's' : '') + ' — each row shows live status', 'info'); var idx = 0, active = 0, done = 0, MAX = 3; function pump() { while (active < MAX && idx < eps.length) { active++; autoGrabEpisode(container, st, sn, eps[idx++], src).then(function () { active--; done++; - if (btn) btn.textContent = done < eps.length ? 'Grabbing ' + done + '/' + eps.length : 'Season queued'; - if (done >= eps.length) { if (btn) btn.disabled = false; } + if (done >= eps.length) { if (btn) { btn.disabled = false; btn.textContent = 'Grab season'; } } else pump(); }); } @@ -895,6 +986,22 @@ pump(); } + // On (re)open, resume live tracking for any episodes of THIS show already in flight, + // so closing/reopening the modal never loses the per-episode progress. + function resumeEpisodeTracking(container, st) { + getJSON('/api/video/downloads/active').then(function (d) { + ((d && d.downloads) || []).forEach(function (dl) { + var ctx = dl.search_ctx; + if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (e) { ctx = null; } } + ctx = ctx || {}; + if (String(ctx.title || dl.title) !== String(st.title)) return; + if (ctx.season == null || ctx.episode == null) return; + var epEl = _epEl(container, ctx.season, ctx.episode); + if (epEl && epEl._epdl == null) { epStatusRender(epEl, dl); epTrack(epEl, dl.id); } + }); + }); + } + function setSeasonSel(container, sn, on) { var st = container._dl; var cbs = container.querySelectorAll('.vdl-season[data-vdl-season="' + sn + '"] .vdl-ep-cb'); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 36f0eaf5..3f61970c 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3298,6 +3298,21 @@ body[data-side="video"] #soulsync-toggle { display: none; } white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .vdl-ep-status { flex-shrink: 0; font-size: 11px; font-weight: 700; color: rgba(255, 255, 255, 0.4); white-space: nowrap; } .vdl-ep-status:empty { display: none; } +/* live per-episode download status (Searching → Downloading % → Downloaded / Failed) */ +.vdl-ep-dl { display: inline-flex; align-items: center; gap: 7px; font-size: 11px; font-weight: 700; white-space: nowrap; } +.vdl-ep-dl--active { color: hsl(var(--vgm-h, 220), 85%, 74%); } +.vdl-ep-dl--done { color: #6cd391; } +.vdl-ep-dl--fail { color: #f0a830; } +.vdl-ep-dl--none { color: rgba(255, 255, 255, 0.4); font-weight: 600; } +.vdl-ep-dl-bar { width: 54px; height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); overflow: hidden; } +.vdl-ep-dl-bar > span { display: block; height: 100%; border-radius: 999px; background: hsl(var(--vgm-h, 220), 85%, 64%); transition: width 0.5s ease; } +.vdl-ep-dl-spin { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; + border: 2px solid rgba(255, 255, 255, 0.22); border-top-color: hsl(var(--vgm-h, 220), 85%, 72%); animation: vdlSpin 0.7s linear infinite; } +/* row-level tint while a download is in flight / done / failed */ +.vdl-ep--dl-active { background: hsla(var(--vgm-h, 220), 60%, 55%, 0.06); } +.vdl-ep--dl-done .vdl-ep-badge--missing { display: none; } +.vdl-ep--dl-active .vdl-ep-badge--missing { display: none; } +.vdl-ep--dl-fail .vdl-ep-badge--missing { display: none; } .vdl-ep-status--scanning { color: hsl(var(--vgm-h, 220), 80%, 72%); } .vdl-ep-status--scanning::after { content: ''; animation: vdlDots 1.1s steps(1) infinite; } .vdl-ep-status--soon { color: #fbcd7a; }