Video: live per-episode download tracking + auto-wishlist today's airings

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.
This commit is contained in:
BoulderBadgeDad 2026-06-21 02:27:08 -07:00
parent a52dda6a7f
commit 01c101d24a
9 changed files with 342 additions and 12 deletions

View file

@ -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},
]

View file

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

View file

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

View file

@ -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',
},
]

View file

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

View file

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

View file

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

View file

@ -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 = '<div class="vdl-ep-srcs">' + srcs.map(function (s) { return srcBlockHTML(s, true); }).join('') + '</div>';
}
// ── 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 = '<span class="vdl-ep-dl vdl-ep-dl--done">&#10003; Downloaded</span>';
} else if (st === 'failed' || st === 'cancelled') {
epEl.classList.add('vdl-ep--dl-fail');
stEl.innerHTML = '<span class="vdl-ep-dl vdl-ep-dl--fail">&#10007; ' + esc(TRACK_LABEL[st] || 'Failed') + '</span>';
} else {
epEl.classList.add('vdl-ep--dl-active');
var label = st === 'downloading' ? ('Downloading ' + pct + '%')
: (st === 'searching' ? 'Searching…' : (TRACK_LABEL[st] || 'Queued'));
stEl.innerHTML = '<span class="vdl-ep-dl vdl-ep-dl--active">' +
'<span class="vdl-ep-dl-bar"><span style="width:' + pct + '%"></span></span>' + esc(label) + '</span>';
}
}
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 = '<span class="vdl-ep-dl vdl-ep-dl--active"><span class="vdl-ep-dl-spin"></span>Searching…</span>';
}
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 = '<span class="vdl-ep-dl vdl-ep-dl--none">No release found</span>';
}
// 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');

View file

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