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
+
+
@@ -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();
+ }
+})();