Video tools: add a Server Scan tool (trigger the media server to index new downloads)
This is the tool originally asked for — DISTINCT from the Library Scan (where
SoulSync reads the server into video.db). Server Scan tells Plex/Jellyfin to
rescan its OWN folders so newly-downloaded files get indexed, then a Library Scan
pulls them in. It's the manual twin of the post-download 'Scan Video Server'
automation, and targets Movies / TV / both like the Library Scan.
- POST /api/video/scan/server {media_type} -> refresh_video_server_sections (trigger)
- GET /api/video/scan/server/status?media_type -> {scanning:true|false|null} (live poll)
- new Server Scan card on the video Tools page + video-server-scan.js controller,
mirroring the music live-status UX (phase + working bar); resumes if the page
opens mid-scan. Server scans have no % (Plex doesn't report one) so the bar is a
working indicator. Both backend functions already existed + are media-type aware.
Seam tests: trigger threads media_type (movie / default all), status reports the
scanning flag (True / null passthrough), and the blueprint exposes both routes.
This commit is contained in:
parent
70ad5af378
commit
d162966cee
4 changed files with 217 additions and 0 deletions
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,31 @@
|
|||
<p class="progress-details-label" data-video-scan-detail>0 movies, 0 shows</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Server Scan: tells the media server to rescan its OWN folders so
|
||||
newly-downloaded files get indexed (distinct from the Library Scan
|
||||
above, where SoulSync READS the server into its DB). -->
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title" data-video-srvscan-title>Server Scan</h4>
|
||||
<button class="tool-help-button" data-tool="video-server-scan" title="Learn more about this tool">?</button>
|
||||
</div>
|
||||
<p class="tool-card-info">Tell the media server to scan its folders for newly-downloaded movies & episodes — then a Library Scan pulls them into SoulSync.</p>
|
||||
<div class="tool-card-controls">
|
||||
<select data-video-srvscan-target aria-label="Which library the server should scan" title="Movies and TV are separate server 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>
|
||||
<button type="button" data-video-srvscan-run>Scan Server</button>
|
||||
</div>
|
||||
<div class="tool-card-progress-section">
|
||||
<p class="progress-phase-label" data-video-srvscan-phase>Idle</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" data-video-srvscan-bar style="width: 0%;"></div>
|
||||
</div>
|
||||
<p class="progress-details-label" data-video-srvscan-detail></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -10425,6 +10450,7 @@
|
|||
<script src="{{ url_for('static', filename='video/video-side.js', v=static_v) }}"></script>
|
||||
<!-- Shared video scan controller (triggers + polls scans for all video pages) -->
|
||||
<script src="{{ url_for('static', filename='video/video-scan.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='video/video-server-scan.js', v=static_v) }}"></script>
|
||||
<!-- Video settings additions (isolated; library mapping on the settings page) -->
|
||||
<script src="{{ url_for('static', filename='video/video-settings.js', v=static_v) }}"></script>
|
||||
<!-- Video enrichment dashboard buttons (isolated; polls /api/video/enrichment) -->
|
||||
|
|
|
|||
131
webui/static/video/video-server-scan.js
Normal file
131
webui/static/video/video-server-scan.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in a new issue