video Tools: match music Database Updater (stats, cancel, real progress)
The scan tool now behaves like music's, not just looks like it: - Card matches: help '?' button, 'Last Scan' line, and the Movies/Shows/ Episodes/Size stats grid (populated from /api/video/dashboard on show + after a scan). Same .tool-card-stats markup. - Real progress bar: scanner fetches item totals up front (Plex section. totalSize / Jellyfin TotalRecordCount) and reports a true percent as it processes; the bar actually moves (movies → shows) instead of sitting at 100%. - Cancel: the Scan button toggles to 'Cancel' mid-scan and POSTs /api/video/scan/stop; the scanner checks a cancel flag between items and ends in a 'cancelled' state. Mirrors music's stop affordance. Tests: percent reported, cancel stops midway + saves only processed items, stop route registered, tool-card structure. 117 video/integrity tests green.
This commit is contained in:
parent
da3f89314b
commit
b5d0b76fe6
8 changed files with 191 additions and 16 deletions
|
|
@ -32,3 +32,9 @@ def register_routes(bp):
|
||||||
from . import get_video_db
|
from . import get_video_db
|
||||||
from core.video.scanner import get_video_scanner
|
from core.video.scanner import get_video_scanner
|
||||||
return jsonify(get_video_scanner(get_video_db()).get_status())
|
return jsonify(get_video_scanner(get_video_db()).get_status())
|
||||||
|
|
||||||
|
@bp.route("/scan/stop", methods=["POST"])
|
||||||
|
def video_scan_stop():
|
||||||
|
from . import get_video_db
|
||||||
|
from core.video.scanner import get_video_scanner
|
||||||
|
return jsonify(get_video_scanner(get_video_db()).cancel())
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class VideoLibraryScanner:
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._status = {"state": "idle"}
|
self._status = {"state": "idle"}
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
self._cancel = False
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -48,6 +49,15 @@ class VideoLibraryScanner:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._status.update(kw)
|
self._status.update(kw)
|
||||||
|
|
||||||
|
def cancel(self) -> dict:
|
||||||
|
"""Request the running scan to stop after the current item."""
|
||||||
|
with self._lock:
|
||||||
|
if self._status.get("state") == "scanning":
|
||||||
|
self._cancel = True
|
||||||
|
self._status["phase"] = "cancelling"
|
||||||
|
return {"status": "cancelling"}
|
||||||
|
return {"status": "idle"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _norm_mode(mode) -> str:
|
def _norm_mode(mode) -> str:
|
||||||
return mode if mode in VALID_MODES else "full"
|
return mode if mode in VALID_MODES else "full"
|
||||||
|
|
@ -59,8 +69,9 @@ class VideoLibraryScanner:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._status.get("state") == "scanning":
|
if self._status.get("state") == "scanning":
|
||||||
return {"status": "in_progress"}
|
return {"status": "in_progress"}
|
||||||
|
self._cancel = False
|
||||||
self._status = {"state": "scanning", "phase": "starting", "mode": mode,
|
self._status = {"state": "scanning", "phase": "starting", "mode": mode,
|
||||||
"started_at": time.time(),
|
"started_at": time.time(), "percent": None,
|
||||||
"movies": 0, "shows": 0, "episodes": 0}
|
"movies": 0, "shows": 0, "episodes": 0}
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._run, args=(source_factory, mode), daemon=True)
|
target=self._run, args=(source_factory, mode), daemon=True)
|
||||||
|
|
@ -71,12 +82,18 @@ class VideoLibraryScanner:
|
||||||
"""Run a scan inline (used by tests / callers that want to block)."""
|
"""Run a scan inline (used by tests / callers that want to block)."""
|
||||||
mode = self._norm_mode(mode)
|
mode = self._norm_mode(mode)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._cancel = False
|
||||||
self._status = {"state": "scanning", "phase": "starting", "mode": mode,
|
self._status = {"state": "scanning", "phase": "starting", "mode": mode,
|
||||||
"started_at": time.time(),
|
"started_at": time.time(), "percent": None,
|
||||||
"movies": 0, "shows": 0, "episodes": 0}
|
"movies": 0, "shows": 0, "episodes": 0}
|
||||||
self._run(source_factory, mode)
|
self._run(source_factory, mode)
|
||||||
return self.get_status()
|
return self.get_status()
|
||||||
|
|
||||||
|
def _finish_cancelled(self, movies, shows, episodes) -> None:
|
||||||
|
self._set(state="cancelled", phase="cancelled", finished_at=time.time(),
|
||||||
|
movies=movies, shows=shows, episodes=episodes)
|
||||||
|
logger.info("Video scan cancelled at %d movies, %d shows", movies, shows)
|
||||||
|
|
||||||
def _run(self, source_factory, mode: str = "full") -> None:
|
def _run(self, source_factory, mode: str = "full") -> None:
|
||||||
try:
|
try:
|
||||||
source = source_factory()
|
source = source_factory()
|
||||||
|
|
@ -88,17 +105,33 @@ class VideoLibraryScanner:
|
||||||
incremental = mode == "incremental"
|
incremental = mode == "incremental"
|
||||||
do_prune = mode == "deep"
|
do_prune = mode == "deep"
|
||||||
|
|
||||||
|
# Totals up front so the progress bar shows a REAL percentage
|
||||||
|
# (movies + shows are the unit; episodes ride along under each show).
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
c = source.counts(incremental=incremental) or {}
|
||||||
|
total = int(c.get("movies", 0) or 0) + int(c.get("shows", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("video scan: counts() unavailable; progress will be indeterminate")
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
def pct():
|
||||||
|
return round(processed / total * 100) if total else None
|
||||||
|
|
||||||
# ── Movies ──
|
# ── Movies ──
|
||||||
self._set(phase="scanning movies")
|
self._set(phase="scanning movies", total=total, percent=pct())
|
||||||
seen_movies: set[str] = set()
|
seen_movies: set[str] = set()
|
||||||
movies = 0
|
movies = 0
|
||||||
for item in source.iter_movies(incremental=incremental):
|
for item in source.iter_movies(incremental=incremental):
|
||||||
|
if self._cancel:
|
||||||
|
return self._finish_cancelled(movies, 0, 0)
|
||||||
self.db.upsert_movie(server, item)
|
self.db.upsert_movie(server, item)
|
||||||
seen_movies.add(str(item["server_id"]))
|
seen_movies.add(str(item["server_id"]))
|
||||||
movies += 1
|
movies += 1
|
||||||
if movies % 25 == 0:
|
processed += 1
|
||||||
self._set(movies=movies)
|
if movies % 10 == 0:
|
||||||
self._set(movies=movies)
|
self._set(movies=movies, percent=pct())
|
||||||
|
self._set(movies=movies, percent=pct())
|
||||||
# Prune ONLY on a deep scan, and only when we actually saw items —
|
# Prune ONLY on a deep scan, and only when we actually saw items —
|
||||||
# so a transient empty response can never wipe the library.
|
# so a transient empty response can never wipe the library.
|
||||||
removed_m = (self.db.prune_missing("movies", server, seen_movies)
|
removed_m = (self.db.prune_missing("movies", server, seen_movies)
|
||||||
|
|
@ -110,16 +143,19 @@ class VideoLibraryScanner:
|
||||||
shows = 0
|
shows = 0
|
||||||
episodes = 0
|
episodes = 0
|
||||||
for show in source.iter_shows(incremental=incremental):
|
for show in source.iter_shows(incremental=incremental):
|
||||||
|
if self._cancel:
|
||||||
|
return self._finish_cancelled(movies, shows, episodes)
|
||||||
self.db.upsert_show_tree(server, show)
|
self.db.upsert_show_tree(server, show)
|
||||||
seen_shows.add(str(show["server_id"]))
|
seen_shows.add(str(show["server_id"]))
|
||||||
shows += 1
|
shows += 1
|
||||||
episodes += sum(len(s.get("episodes", [])) for s in show.get("seasons", []))
|
episodes += sum(len(s.get("episodes", [])) for s in show.get("seasons", []))
|
||||||
self._set(shows=shows, episodes=episodes)
|
processed += 1
|
||||||
|
self._set(shows=shows, episodes=episodes, percent=pct())
|
||||||
removed_s = (self.db.prune_missing("shows", server, seen_shows)
|
removed_s = (self.db.prune_missing("shows", server, seen_shows)
|
||||||
if do_prune and seen_shows else 0)
|
if do_prune and seen_shows else 0)
|
||||||
|
|
||||||
self._set(state="done", phase="complete", finished_at=time.time(),
|
self._set(state="done", phase="complete", finished_at=time.time(),
|
||||||
movies=movies, shows=shows, episodes=episodes,
|
movies=movies, shows=shows, episodes=episodes, percent=100,
|
||||||
removed=removed_m + removed_s)
|
removed=removed_m + removed_s)
|
||||||
logger.info("Video scan (%s) complete: %d movies, %d shows, %d episodes (%d pruned)",
|
logger.info("Video scan (%s) complete: %d movies, %d shows, %d episodes (%d pruned)",
|
||||||
mode, movies, shows, episodes, removed_m + removed_s)
|
mode, movies, shows, episodes, removed_m + removed_s)
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,14 @@ class PlexVideoSource:
|
||||||
"tv": [{"title": s.title} for s in self._sections("show")],
|
"tv": [{"title": s.title} for s in self._sections("show")],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def counts(self, incremental=False) -> dict:
|
||||||
|
"""Cheap item totals (no full fetch) for the progress bar."""
|
||||||
|
m = sum(int(getattr(s, "totalSize", 0) or 0) for s in self._sections("movie", self._movies_lib))
|
||||||
|
sh = sum(int(getattr(s, "totalSize", 0) or 0) for s in self._sections("show", self._tv_lib))
|
||||||
|
if incremental:
|
||||||
|
m, sh = min(m, 100), min(sh, 50)
|
||||||
|
return {"movies": m, "shows": sh}
|
||||||
|
|
||||||
def iter_movies(self, incremental=False):
|
def iter_movies(self, incremental=False):
|
||||||
for section in self._sections("movie", self._movies_lib):
|
for section in self._sections("movie", self._movies_lib):
|
||||||
items = section.search(sort="addedAt:desc", maxresults=100) if incremental else section.all()
|
items = section.search(sort="addedAt:desc", maxresults=100) if incremental else section.all()
|
||||||
|
|
@ -235,6 +243,18 @@ class JellyfinVideoSource:
|
||||||
"tv": [{"title": v.get("Name")} for v in self._views("tvshows")],
|
"tv": [{"title": v.get("Name")} for v in self._views("tvshows")],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def counts(self, incremental=False) -> dict:
|
||||||
|
def total(view, itype):
|
||||||
|
resp = self._req(f"/Users/{self.uid}/Items", {
|
||||||
|
"ParentId": view["Id"], "IncludeItemTypes": itype,
|
||||||
|
"Recursive": "true", "Limit": "0"}) or {}
|
||||||
|
return int(resp.get("TotalRecordCount", 0) or 0)
|
||||||
|
m = sum(total(v, "Movie") for v in self._views("movies", self._movies_lib))
|
||||||
|
sh = sum(total(v, "Series") for v in self._views("tvshows", self._tv_lib))
|
||||||
|
if incremental:
|
||||||
|
m, sh = min(m, 100), min(sh, 50)
|
||||||
|
return {"movies": m, "shows": sh}
|
||||||
|
|
||||||
def _paged(self, path, params, page_size=500):
|
def _paged(self, path, params, page_size=500):
|
||||||
"""Yield items across pages so large libraries aren't capped/truncated."""
|
"""Yield items across pages so large libraries aren't capped/truncated."""
|
||||||
start = 0
|
start = 0
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ def test_blueprint_exposes_dashboard_route():
|
||||||
assert "/api/video/dashboard" in rules
|
assert "/api/video/dashboard" in rules
|
||||||
assert "/api/video/scan/request" in rules
|
assert "/api/video/scan/request" in rules
|
||||||
assert "/api/video/scan/status" in rules
|
assert "/api/video/scan/status" in rules
|
||||||
|
assert "/api/video/scan/stop" in rules
|
||||||
assert "/api/video/library" in rules
|
assert "/api/video/library" in rules
|
||||||
assert "/api/video/libraries" in rules
|
assert "/api/video/libraries" in rules
|
||||||
assert any(r.startswith("/api/video/poster/") for r in rules)
|
assert any(r.startswith("/api/video/poster/") for r in rules)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,42 @@ def test_scan_sync_no_source_reports_error(db):
|
||||||
assert st["state"] == "error" and "error" in st
|
assert st["state"] == "error" and "error" in st
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_reports_percent_from_counts(db):
|
||||||
|
class S:
|
||||||
|
server_name = "plex"
|
||||||
|
def counts(self, incremental=False):
|
||||||
|
return {"movies": 4, "shows": 0}
|
||||||
|
def iter_movies(self, incremental=False):
|
||||||
|
return iter([{"server_id": "m%d" % i, "title": str(i)} for i in range(4)])
|
||||||
|
def iter_shows(self, incremental=False):
|
||||||
|
return iter([])
|
||||||
|
st = VideoLibraryScanner(db).scan_sync(lambda: S())
|
||||||
|
assert st["state"] == "done"
|
||||||
|
assert st["percent"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_cancel_stops_midway(db):
|
||||||
|
scanner = VideoLibraryScanner(db)
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
yield {"server_id": "m1", "title": "A"}
|
||||||
|
scanner.cancel() # request stop after the first item
|
||||||
|
yield {"server_id": "m2", "title": "B"}
|
||||||
|
|
||||||
|
class S:
|
||||||
|
server_name = "plex"
|
||||||
|
def counts(self, incremental=False):
|
||||||
|
return {"movies": 2, "shows": 0}
|
||||||
|
def iter_movies(self, incremental=False):
|
||||||
|
return gen()
|
||||||
|
def iter_shows(self, incremental=False):
|
||||||
|
return iter([])
|
||||||
|
|
||||||
|
st = scanner.scan_sync(lambda: S())
|
||||||
|
assert st["state"] == "cancelled"
|
||||||
|
assert db.dashboard_stats()["library"]["movies"] == 1 # only the first was saved
|
||||||
|
|
||||||
|
|
||||||
def test_core_video_imports_nothing_from_music():
|
def test_core_video_imports_nothing_from_music():
|
||||||
base = Path(__file__).resolve().parent.parent / "core" / "video"
|
base = Path(__file__).resolve().parent.parent / "core" / "video"
|
||||||
for py in base.glob("*.py"):
|
for py in base.glob("*.py"):
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,10 @@ def test_video_tools_page_has_three_scan_modes():
|
||||||
assert "data-video-scan-run" in block and "data-video-scan-select" in block
|
assert "data-video-scan-run" in block and "data-video-scan-select" in block
|
||||||
assert "data-video-scan-phase" in block # reuses music progress markup
|
assert "data-video-scan-phase" in block # reuses music progress markup
|
||||||
assert "tool-card" in block and "tool-card-controls" in block # reuses music classes
|
assert "tool-card" in block and "tool-card-controls" in block # reuses music classes
|
||||||
|
# Matches the music Database Updater card: help button, last-scan line, stats grid.
|
||||||
|
assert "tool-help-button" in block
|
||||||
|
assert "data-video-scan-last" in block
|
||||||
|
assert "tool-card-stats" in block and 'data-video-scan-stat="movies"' in block
|
||||||
assert "onclick" not in block
|
assert "onclick" not in block
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -762,8 +762,27 @@
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header">
|
<div class="tool-card-header">
|
||||||
<h4 class="tool-card-title">Library Scan</h4>
|
<h4 class="tool-card-title">Library Scan</h4>
|
||||||
|
<button class="tool-help-button" data-tool="video-scan" title="Learn more about this tool">?</button>
|
||||||
|
</div>
|
||||||
|
<p class="tool-card-info">Last Scan: <span data-video-scan-last>Never</span></p>
|
||||||
|
<div class="tool-card-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">Movies:</span>
|
||||||
|
<span class="stat-item-value" data-video-scan-stat="movies">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">Shows:</span>
|
||||||
|
<span class="stat-item-value" data-video-scan-stat="shows">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">Episodes:</span>
|
||||||
|
<span class="stat-item-value" data-video-scan-stat="episodes">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-item-label">Size:</span>
|
||||||
|
<span class="stat-item-value" data-video-scan-stat="size">0.0 MB</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="tool-card-info">Pull movies & shows from your media server. Incremental = recently-added only; Full Refresh = re-read everything; Deep Scan = also remove items no longer on the server.</p>
|
|
||||||
<div class="tool-card-controls">
|
<div class="tool-card-controls">
|
||||||
<select data-video-scan-select>
|
<select data-video-scan-select>
|
||||||
<option value="incremental">Incremental Update</option>
|
<option value="incremental">Incremental Update</option>
|
||||||
|
|
|
||||||
|
|
@ -40,25 +40,70 @@
|
||||||
return (s.movies || 0) + ' movies, ' + (s.shows || 0) + ' shows';
|
return (s.movies || 0) + ' movies, ' + (s.shows || 0) + ' shows';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
bytes = Number(bytes) || 0;
|
||||||
|
var mb = bytes / 1048576;
|
||||||
|
if (mb >= 1048576) return (mb / 1048576).toFixed(2) + ' TB';
|
||||||
|
if (mb >= 1024) return (mb / 1024).toFixed(2) + ' GB';
|
||||||
|
return mb.toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the Tools card stat grid (Movies/Shows/Episodes/Size) from the
|
||||||
|
// same dashboard endpoint — reuses existing data, no new API.
|
||||||
|
function loadToolStats() {
|
||||||
|
if (!document.querySelector('[data-video-scan-stat]')) return;
|
||||||
|
fetch('/api/video/dashboard', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d || d.error) return;
|
||||||
|
var lib = d.library || {};
|
||||||
|
var set = function (k, v) {
|
||||||
|
var n = document.querySelector('[data-video-scan-stat="' + k + '"]');
|
||||||
|
if (n) n.textContent = v;
|
||||||
|
};
|
||||||
|
set('movies', lib.movies || 0);
|
||||||
|
set('shows', lib.shows || 0);
|
||||||
|
set('episodes', lib.episodes || 0);
|
||||||
|
set('size', formatSize(lib.size_bytes));
|
||||||
|
})
|
||||||
|
.catch(function () { /* leave defaults */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRunLabel(text) {
|
||||||
|
var run = document.querySelector('[data-video-scan-run]');
|
||||||
|
if (run) run.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
function reflectProgress(s) {
|
function reflectProgress(s) {
|
||||||
var phase = (s.phase || 'scanning');
|
var phase = (s.phase || 'scanning');
|
||||||
setText('[data-video-scan-phase]', phase.charAt(0).toUpperCase() + phase.slice(1));
|
setText('[data-video-scan-phase]', phase.charAt(0).toUpperCase() + phase.slice(1));
|
||||||
setText('[data-video-scan-detail]', counts(s));
|
var detail = counts(s);
|
||||||
setBar(100);
|
if (s.percent != null) detail += ' · ' + s.percent + '%';
|
||||||
|
setText('[data-video-scan-detail]', detail);
|
||||||
|
// Real percentage when we know the total; otherwise a full bar as a
|
||||||
|
// "working" indicator.
|
||||||
|
setBar(s.percent != null ? s.percent : 100);
|
||||||
|
setRunLabel('Cancel'); // button doubles as cancel while scanning
|
||||||
}
|
}
|
||||||
|
|
||||||
function reflectDone(s) {
|
function reflectDone(s) {
|
||||||
var run = document.querySelector('[data-video-scan-run]');
|
setRunLabel('Scan Library');
|
||||||
if (run) run.disabled = false;
|
|
||||||
if (s.state === 'error') {
|
if (s.state === 'error') {
|
||||||
setText('[data-video-scan-phase]', 'Failed');
|
setText('[data-video-scan-phase]', 'Failed');
|
||||||
setText('[data-video-scan-detail]', s.error || 'Scan failed');
|
setText('[data-video-scan-detail]', s.error || 'Scan failed');
|
||||||
setBar(0);
|
setBar(0);
|
||||||
|
} else if (s.state === 'cancelled') {
|
||||||
|
setText('[data-video-scan-phase]', 'Cancelled');
|
||||||
|
setText('[data-video-scan-detail]', counts(s) + ' (cancelled)');
|
||||||
|
setBar(0);
|
||||||
|
loadToolStats();
|
||||||
} else {
|
} else {
|
||||||
setText('[data-video-scan-phase]', 'Complete');
|
setText('[data-video-scan-phase]', 'Complete');
|
||||||
setText('[data-video-scan-detail]',
|
setText('[data-video-scan-detail]',
|
||||||
counts(s) + (s.removed ? ', ' + s.removed + ' removed' : ''));
|
counts(s) + (s.removed ? ', ' + s.removed + ' removed' : ''));
|
||||||
setBar(100);
|
setBar(100);
|
||||||
|
loadToolStats();
|
||||||
|
try { setText('[data-video-scan-last]', new Date().toLocaleString()); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,9 +131,7 @@
|
||||||
function start(mode) {
|
function start(mode) {
|
||||||
if (scanning) return;
|
if (scanning) return;
|
||||||
scanning = true;
|
scanning = true;
|
||||||
var run = document.querySelector('[data-video-scan-run]');
|
var pending = { state: 'scanning', phase: 'starting', mode: mode, movies: 0, shows: 0, percent: 0 };
|
||||||
if (run) run.disabled = true;
|
|
||||||
var pending = { state: 'scanning', phase: 'starting', mode: mode, movies: 0, shows: 0 };
|
|
||||||
reflectProgress(pending);
|
reflectProgress(pending);
|
||||||
emit('soulsync:video-scan-progress', pending);
|
emit('soulsync:video-scan-progress', pending);
|
||||||
fetch(REQUEST_URL, {
|
fetch(REQUEST_URL, {
|
||||||
|
|
@ -104,6 +147,12 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
setText('[data-video-scan-phase]', 'Cancelling…');
|
||||||
|
fetch('/api/video/scan/stop', { method: 'POST', headers: { 'Accept': 'application/json' } })
|
||||||
|
.catch(function () { /* poll will reconcile */ });
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
var triggers = document.querySelectorAll('[data-video-scan-mode],[data-video-scan]');
|
var triggers = document.querySelectorAll('[data-video-scan-mode],[data-video-scan]');
|
||||||
for (var i = 0; i < triggers.length; i++) {
|
for (var i = 0; i < triggers.length; i++) {
|
||||||
|
|
@ -118,6 +167,7 @@
|
||||||
if (run) {
|
if (run) {
|
||||||
run.addEventListener('click', function (e) {
|
run.addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (scanning) { stop(); return; } // button doubles as Cancel mid-scan
|
||||||
var sel = document.querySelector('[data-video-scan-select]');
|
var sel = document.querySelector('[data-video-scan-select]');
|
||||||
start(sel ? sel.value : 'full');
|
start(sel ? sel.value : 'full');
|
||||||
});
|
});
|
||||||
|
|
@ -125,6 +175,9 @@
|
||||||
document.addEventListener('soulsync:video-scan-start', function (e) {
|
document.addEventListener('soulsync:video-scan-start', function (e) {
|
||||||
start((e.detail && e.detail.mode) || 'full');
|
start((e.detail && e.detail.mode) || 'full');
|
||||||
});
|
});
|
||||||
|
document.addEventListener('soulsync:video-page-shown', function (e) {
|
||||||
|
if (e && e.detail === 'video-tools') loadToolStats();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue