diff --git a/api/video/scan.py b/api/video/scan.py index 06c3eb58..86f32bc3 100644 --- a/api/video/scan.py +++ b/api/video/scan.py @@ -41,3 +41,21 @@ def register_routes(bp): from . import get_video_db from core.video.scanner import get_video_scanner return jsonify(get_video_scanner(get_video_db()).cancel()) + + # ── Server-side scan (distinct from the SoulSync-reads-server scan above) ── + # This tells the media server (Plex/Jellyfin) to rescan its OWN folders so + # newly-downloaded files get indexed — the manual twin of the post-download + # 'Scan Video Server' automation. media_type scopes it to Movies / TV / both. + @bp.route("/scan/server", methods=["POST"]) + def video_scan_server(): + from core.video.sources import refresh_video_server_sections + body = request.get_json(silent=True) or {} + media_type = body.get("media_type", "all") + return jsonify(refresh_video_server_sections(media_type)) + + @bp.route("/scan/server/status", methods=["GET"]) + def video_scan_server_status(): + # {scanning: true|false|null} — null when the adapter can't report state. + from core.video.sources import video_server_scan_in_progress + media_type = request.args.get("media_type", "all") + return jsonify({"scanning": video_server_scan_in_progress(media_type)}) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 72c89cc6..b7a4671b 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -32,6 +32,8 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/scan/request" in rules assert "/api/video/scan/status" in rules assert "/api/video/scan/stop" in rules + assert "/api/video/scan/server" in rules + assert "/api/video/scan/server/status" in rules assert "/api/video/library" in rules assert "/api/video/libraries" in rules assert "/api/video/server" in rules @@ -94,6 +96,46 @@ def test_scan_request_threads_mode_and_media_type(tmp_path, monkeypatch): assert calls["media_type"] == "show" +def test_server_scan_triggers_refresh_with_media_type(tmp_path, monkeypatch): + # The Server Scan tool tells the media server to rescan its OWN folders. The + # endpoint must thread media_type (Movies / TV / both) to the source refresh. + client, _ = _make_client(tmp_path) + calls = {} + + import core.video.sources as sources_mod + + def _refresh(media_type="all"): + calls["mt"] = media_type + return {"ok": True, "sections": [media_type]} + monkeypatch.setattr(sources_mod, "refresh_video_server_sections", _refresh) + + r = client.post("/api/video/scan/server", json={"media_type": "movie"}) + assert r.get_json() == {"ok": True, "sections": ["movie"]} + assert calls["mt"] == "movie" + + calls.clear() + client.post("/api/video/scan/server", json={}) # default → both libraries + assert calls["mt"] == "all" + + +def test_server_scan_status_reports_scanning_flag(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path) + import core.video.sources as sources_mod + + seen = {} + + def _inprog(media_type="all"): + seen["mt"] = media_type + return True + monkeypatch.setattr(sources_mod, "video_server_scan_in_progress", _inprog) + assert client.get("/api/video/scan/server/status?media_type=show").get_json() == {"scanning": True} + assert seen["mt"] == "show" + + # None (adapter can't report) passes straight through as JSON null + monkeypatch.setattr(sources_mod, "video_server_scan_in_progress", lambda mt="all": None) + assert client.get("/api/video/scan/server/status").get_json() == {"scanning": None} + + def test_discover_trailer_returns_key(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import core.video.enrichment.engine as eng_mod diff --git a/webui/index.html b/webui/index.html index 0d416577..ee3b7304 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1636,6 +1636,31 @@

0 movies, 0 shows

+ +
+
+

Server Scan

+ +
+

Tell the media server to scan its folders for newly-downloaded movies & episodes — then a Library Scan pulls them into SoulSync.

+
+ + +
+
+

Idle

+
+
+
+

+
+
@@ -10425,6 +10450,7 @@ + diff --git a/webui/static/video/video-server-scan.js b/webui/static/video/video-server-scan.js new file mode 100644 index 00000000..c05d4ee7 --- /dev/null +++ b/webui/static/video/video-server-scan.js @@ -0,0 +1,131 @@ +/* Video Tools — Server Scan card. + * + * DISTINCT from video-scan.js (the Library Scan, where SoulSync READS the server + * into video.db). This one tells the media server (Plex/Jellyfin) to rescan its + * OWN folders so newly-downloaded files get indexed — the manual twin of the + * post-download "Scan Video Server" automation. + * + * Backend: + * POST /api/video/scan/server {media_type} -> trigger ({ok, sections} | {ok:false,error}) + * GET /api/video/scan/server/status ?media_type -> {scanning: true|false|null} + * + * Self-contained IIFE, no globals; mirrors the music/library-scan live-status UX. + * media_type targets Movies / TV / both, like the Library Scan card. + */ +(function () { + var TRIGGER_URL = '/api/video/scan/server'; + var STATUS_URL = '/api/video/scan/server/status'; + var scanning = false; + var pollTimer = null; + + function $(sel) { return document.querySelector(sel); } + function setText(sel, t) { var n = $(sel); if (n) n.textContent = t; } + function setBar(p) { var b = $('[data-video-srvscan-bar]'); if (b) b.style.width = p + '%'; } + function setRunLabel(t) { var r = $('[data-video-srvscan-run]'); if (r) r.textContent = t; } + + function targetValue() { + var t = $('[data-video-srvscan-target]'); + return t ? t.value : 'all'; + } + function targetLabel(mt) { + return mt === 'movie' ? 'Movies' : (mt === 'show' ? 'TV' : 'Movies + TV'); + } + + function stopPoll() { if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } } + + function done(phase, detail) { + scanning = false; + stopPoll(); + setRunLabel('Scan Server'); + setText('[data-video-srvscan-phase]', phase); + setText('[data-video-srvscan-detail]', detail || ''); + setBar(phase === 'Complete' ? 100 : 0); + } + + // The server scan has no percentage (Plex doesn't report one), so the bar is a + // full-width "working" indicator while scanning, then settles on complete. + function poll(mt) { + fetch(STATUS_URL + '?media_type=' + encodeURIComponent(mt), { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.json(); }) + .then(function (s) { + if (!s || s.scanning === false) { done('Complete', targetLabel(mt) + ' indexed'); return; } + if (s.scanning === null) { done('Scan started', "Server can't report progress"); return; } + setText('[data-video-srvscan-phase]', 'Scanning…'); + setText('[data-video-srvscan-detail]', 'Server is indexing ' + targetLabel(mt) + '…'); + setBar(100); + pollTimer = setTimeout(function () { poll(mt); }, 2000); + }) + .catch(function () { done('Idle', ''); }); + } + + function start() { + if (scanning) return; + var mt = targetValue(); + scanning = true; + setRunLabel('Scanning…'); + setText('[data-video-srvscan-phase]', 'Starting…'); + setText('[data-video-srvscan-detail]', 'Asking the server to scan ' + targetLabel(mt) + '…'); + setBar(100); + fetch(TRIGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ media_type: mt }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res && res.ok === false) { done('Failed', res.error || 'Server scan failed'); return; } + pollTimer = setTimeout(function () { poll(mt); }, 800); + }) + .catch(function () { done('Failed', 'Could not reach server'); }); + } + + // Server-prefixed title to match the Library Scan card ("Plex Server Scan"). + function loadTitle() { + var titleEl = $('[data-video-srvscan-title]'); + if (!titleEl) return; + fetch('/api/video/dashboard', { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (d && d.server) { + titleEl.textContent = d.server.charAt(0).toUpperCase() + d.server.slice(1) + ' Server Scan'; + } + }) + .catch(function () { /* leave default */ }); + } + + // If the server is already mid-scan when the page opens, restore the live UI. + function resumeIfScanning() { + if (scanning) return; + var mt = targetValue(); + fetch(STATUS_URL + '?media_type=' + encodeURIComponent(mt), { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.json(); }) + .then(function (s) { + if (s && s.scanning === true && !scanning) { + scanning = true; + setRunLabel('Scanning…'); + setText('[data-video-srvscan-phase]', 'Scanning…'); + setBar(100); + pollTimer = setTimeout(function () { poll(mt); }, 1200); + } + }) + .catch(function () { /* ignore */ }); + } + + function init() { + var run = $('[data-video-srvscan-run]'); + if (run) { + run.addEventListener('click', function (e) { e.preventDefault(); start(); }); + } + document.addEventListener('soulsync:video-page-shown', function (e) { + if (e && e.detail === 'video-tools') { loadTitle(); resumeIfScanning(); } + }); + loadTitle(); + resumeIfScanning(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();