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:
parent
a52dda6a7f
commit
01c101d24a
9 changed files with 342 additions and 12 deletions
|
|
@ -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},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
core/automation/handlers/video_auto_wishlist_airing.py
Normal file
89
core/automation/handlers/video_auto_wishlist_airing.py
Normal 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}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
74
tests/test_video_auto_wishlist_airing.py
Normal file
74
tests/test_video_auto_wishlist_airing.py
Normal 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)
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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">✓ 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">✗ ' + 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');
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue