Video tools: scan a Movies OR TV library (not just both)

The video Library Scan tool only scanned 'all' — but movies and TV are
independent libraries (unlike music's single library). The scanner backend
already supported media_type='movie'|'show'|'all'; this just wires it up:
- /api/video/scan/request now reads media_type and threads it to request_scan
- the Tools card gains a target selector (All / Movies Only / TV Shows Only)
  alongside the existing mode dropdown, matching the music scan's UX
- the live status detail reflects the target (no confusing '0 shows' on a
  movies-only scan)

Seam test: the endpoint passes both mode and media_type through (default all/full,
explicit movie/deep, TV-only). Existing scanner media-type/scope tests unchanged.
This commit is contained in:
BoulderBadgeDad 2026-06-21 23:27:35 -07:00
parent 66bce2b83a
commit b26f664326
4 changed files with 55 additions and 7 deletions

View file

@ -24,8 +24,11 @@ def register_routes(bp):
from core.video.sources import get_active_video_source
body = request.get_json(silent=True) or {}
mode = body.get("mode", "full")
# Which library to scan — movies and TV are independent libraries, so the
# UI can target one or both. The scanner normalizes/validates it.
media_type = body.get("media_type", "all")
scanner = get_video_scanner(get_video_db())
return jsonify(scanner.request_scan(get_active_video_source, mode))
return jsonify(scanner.request_scan(get_active_video_source, mode, media_type))
@bp.route("/scan/status", methods=["GET"])
def video_scan_status():

View file

@ -62,6 +62,38 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/discover/trailer" in rules
def test_scan_request_threads_mode_and_media_type(tmp_path, monkeypatch):
# The Tools-page scan can target one library (movies / TV) or both. The
# endpoint must pass BOTH mode and media_type through to the scanner — movies
# and TV are independent, so a TV scan must never touch movies.
client, _ = _make_client(tmp_path)
calls = {}
class _FakeScanner:
def request_scan(self, source_factory, mode="full", media_type="all"):
calls["mode"] = mode
calls["media_type"] = media_type
return {"status": "started", "mode": mode, "media_type": media_type}
import core.video.scanner as scanner_mod
import core.video.sources as sources_mod
monkeypatch.setattr(scanner_mod, "get_video_scanner", lambda db: _FakeScanner())
monkeypatch.setattr(sources_mod, "get_active_video_source", lambda: None)
# default body → both libraries, full
assert client.post("/api/video/scan/request", json={}).get_json()["media_type"] == "all"
assert calls == {"mode": "full", "media_type": "all"}
# explicit movies-only deep scan
r = client.post("/api/video/scan/request", json={"mode": "deep", "media_type": "movie"})
assert calls == {"mode": "deep", "media_type": "movie"}
assert r.get_json() == {"status": "started", "mode": "deep", "media_type": "movie"}
# TV-only
client.post("/api/video/scan/request", json={"media_type": "show"})
assert calls["media_type"] == "show"
def test_discover_trailer_returns_key(tmp_path, monkeypatch):
client, _ = _make_client(tmp_path)
import core.video.enrichment.engine as eng_mod

View file

@ -1616,7 +1616,12 @@
</div>
</div>
<div class="tool-card-controls">
<select data-video-scan-select>
<select data-video-scan-target aria-label="Which library to scan" title="Movies and TV are separate libraries — scan one or both">
<option value="all">All Libraries</option>
<option value="movie">Movies Only</option>
<option value="show">TV Shows Only</option>
</select>
<select data-video-scan-select aria-label="Scan type">
<option value="incremental">Incremental Update</option>
<option value="full">Full Refresh</option>
<option value="deep">Deep Scan</option>

View file

@ -37,6 +37,11 @@
}
function counts(s) {
// Reflect the scan target so a movies-only / TV-only scan doesn't show a
// confusing "0 shows" half. 'all' shows both.
var mt = s.media_type || 'all';
if (mt === 'movie') return (s.movies || 0) + ' movies';
if (mt === 'show') return (s.shows || 0) + ' shows, ' + (s.episodes || 0) + ' episodes';
return (s.movies || 0) + ' movies, ' + (s.shows || 0) + ' shows';
}
@ -134,16 +139,18 @@
});
}
function start(mode) {
function start(mode, mediaType) {
if (scanning) return;
scanning = true;
var pending = { state: 'scanning', phase: 'starting', mode: mode, movies: 0, shows: 0, percent: 0 };
mediaType = mediaType || 'all'; // 'all' | 'movie' | 'show'
var pending = { state: 'scanning', phase: 'starting', mode: mode,
media_type: mediaType, movies: 0, shows: 0, percent: 0 };
reflectProgress(pending);
emit('soulsync:video-scan-progress', pending);
fetch(REQUEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ mode: mode })
body: JSON.stringify({ mode: mode, media_type: mediaType })
})
.then(function () { setTimeout(poll, 600); })
.catch(function () {
@ -193,11 +200,12 @@
e.preventDefault();
if (scanning) { stop(); return; } // button doubles as Cancel mid-scan
var sel = document.querySelector('[data-video-scan-select]');
start(sel ? sel.value : 'full');
var tgt = document.querySelector('[data-video-scan-target]');
start(sel ? sel.value : 'full', tgt ? tgt.value : 'all');
});
}
document.addEventListener('soulsync:video-scan-start', function (e) {
start((e.detail && e.detail.mode) || 'full');
start((e.detail && e.detail.mode) || 'full', (e.detail && e.detail.media_type) || 'all');
});
document.addEventListener('soulsync:video-page-shown', function (e) {
if (e && e.detail === 'video-tools') { loadToolStats(); resumeIfScanning(); }