diff --git a/api/video/downloads.py b/api/video/downloads.py index 893c867d..54a6244b 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -292,6 +292,33 @@ def register_routes(bp): ensure_started(get_video_db) # also (re)start the monitor when the page is open return jsonify({"downloads": db.list_video_downloads()}) + @bp.route("/downloads/status", methods=["GET"]) + def video_downloads_status(): + """Lightweight live-tracking lookup — used by the Download modal's result + card (by ``id``) and a movie/show detail page (by ``media_id`` + + ``media_source``, returning that title's most relevant download so the page + can show live progress). Returns ``{"download": {...}|null}``.""" + from . import get_video_db + from core.video.download_monitor import ensure_started + db = get_video_db() + ensure_started(get_video_db) + dl_id = request.args.get("id") + if dl_id: + try: + return jsonify({"download": db.get_video_download(int(dl_id))}) + except (TypeError, ValueError): + return jsonify({"download": None}) + media_id = request.args.get("media_id") + if media_id: + media_source = request.args.get("media_source") + match = [r for r in db.list_video_downloads() + if str(r.get("media_id")) == str(media_id) + and (not media_source or r.get("media_source") == media_source)] + # list_video_downloads orders active-first then newest — so an active + # download (or else the most recent) for this title is simply match[0]. + return jsonify({"download": match[0] if match else None}) + return jsonify({"download": None}) + @bp.route("/downloads/cancel", methods=["POST"]) def video_downloads_cancel(): from . import get_video_db diff --git a/tests/test_video_api.py b/tests/test_video_api.py index f3a53f28..6da0b9b3 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -541,6 +541,45 @@ def test_downloads_grab_and_active(tmp_path, monkeypatch): videoapi._video_db = None +def test_downloads_status_lookup_by_id_and_media(tmp_path, monkeypatch): + """The live-tracking endpoint: the modal result card looks up by download id, + a movie detail page looks up by media_id+media_source. Powers both progress UIs.""" + import api.video as videoapi + import core.video.download_monitor as mon + import core.video.slskd_download as slskd + from database.video_database import VideoDatabase + + monkeypatch.setattr(slskd, "start_download", lambda *a, **k: {"ok": True}) + monkeypatch.setattr(mon, "ensure_started", lambda *a, **k: None) + + db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + db.set_setting("movies_path", "/media/movies") + videoapi._video_db = db + app = Flask(__name__) + app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") + client = app.test_client() + try: + gid = client.post("/api/video/downloads/grab", json={ + "kind": "movie", "title": "The Matrix", "source": "soulseek", + "username": "neo", "filename": "m.mkv", "size_bytes": 8, "quality_label": "1080p", + "media_id": 42, "media_source": "library"}).get_json()["id"] + + # by id + byid = client.get("/api/video/downloads/status?id=" + str(gid)).get_json()["download"] + assert byid and byid["id"] == gid and byid["status"] == "downloading" + + # by media identity (what the movie detail page uses) + bym = client.get("/api/video/downloads/status?media_id=42&media_source=library").get_json()["download"] + assert bym and bym["id"] == gid + + # a different movie / unknown id → null, never an error + assert client.get("/api/video/downloads/status?media_id=999&media_source=library").get_json()["download"] is None + assert client.get("/api/video/downloads/status?id=123456").get_json()["download"] is None + assert client.get("/api/video/downloads/status").get_json()["download"] is None + finally: + videoapi._video_db = None + + def test_slskd_config_shared_via_config_manager(tmp_path, monkeypatch): import api.video as videoapi import config.settings as cfg diff --git a/tests/test_video_download_tracking.py b/tests/test_video_download_tracking.py new file mode 100644 index 00000000..60b269c7 --- /dev/null +++ b/tests/test_video_download_tracking.py @@ -0,0 +1,73 @@ +"""Live download tracking wiring: after a grab the user must (a) see WHICH release +was selected, (b) get a live progress bar on it, and (c) get a button to the +Downloads page — and a movie's detail page shows live progress for an in-flight +download that jumps back to Downloads. + +Backed by GET /api/video/downloads/status (tested in test_video_api.py +::test_downloads_status_lookup_by_id_and_media). These pin the frontend wiring so +a refactor can't quietly unhook the tracker, the detail chip, or the navigation. +""" + +from __future__ import annotations + +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_VIEW = (_ROOT / "webui" / "static" / "video" / "video-download-view.js").read_text(encoding="utf-8") +_DETAIL = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8") +_SIDE = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8") +_GETMODAL = (_ROOT / "webui" / "static" / "video" / "video-get-modal.js").read_text(encoding="utf-8") +_CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encoding="utf-8") + + +# --- result card: redesign + selected/tracking state ---------------------- + +def test_result_card_redesigned_as_column_with_get_button(): + assert 'vdl-res-main' in _VIEW # row wrapper so a tracker can dock below + assert 'data-vdl-card="' in _VIEW # addressable card (auto-pick targets it) + assert 'vdl-res-grab-ic' in _VIEW # the grab button is now a labelled "Get" pill + assert '.vdl-res-main' in _CSS + + +def test_grab_begins_live_tracking_on_the_card(): + assert 'function beginTracking(' in _VIEW + assert 'vdl-res--grabbed' in _VIEW + assert 'data-vdl-track-fill' in _VIEW # the progress bar fill + assert "/api/video/downloads/status?id=" in _VIEW + # both the manual grab and the auto-pick start tracking the chosen card + assert _VIEW.count('beginTracking(') >= 3 # 1 def + doGrab + _autoPick + + +def test_track_button_goes_to_downloads_and_closes_modal(): + assert 'Track on Downloads' in _VIEW + assert 'function gotoDownloads(' in _VIEW + assert 'VideoGet.close' in _VIEW + assert "'soulsync:video-navigate'" in _VIEW + assert '.vdl-res-track' in _CSS + + +def test_modal_exposes_close(): + assert 'close: closeModal' in _GETMODAL + + +# --- movie detail page: live download chip -------------------------------- + +def test_detail_page_watches_movie_download(): + assert 'function watchMovieDownload(' in _DETAIL + assert '/api/video/downloads/status?media_id=' in _DETAIL + assert 'data-vd-dlchip' in _DETAIL + # the chip jumps to the Downloads page + assert "'soulsync:video-navigate'" in _DETAIL + assert '.vd-dlchip' in _CSS + + +def test_detail_watch_started_for_library_movies(): + assert 'watchMovieDownload(id)' in _DETAIL + assert 'stopMovieDownloadWatch()' in _DETAIL # cleared on (re)load / navigate away + + +# --- navigation plumbing -------------------------------------------------- + +def test_video_side_handles_navigate_event(): + assert "'soulsync:video-navigate'" in _SIDE + assert 'navigate(pageId)' in _SIDE diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 19bf0b0d..d201dff6 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -1246,11 +1246,65 @@ } // ── movie detail (flat) ─────────────────────────────────────────────────── + // ── live download status (a movie being grabbed shows progress here, and the + // chip jumps to the Downloads page) ───────────────────────────────────── + var _dlWatch = { id: null, t: null }; + function stopMovieDownloadWatch() { + if (_dlWatch.t) { clearTimeout(_dlWatch.t); _dlWatch.t = null; } + _dlWatch.id = null; + var c = q('[data-vd-dlchip]'); if (c) c.remove(); + } + function renderMovieDownloadChip(dl) { + var a = q('[data-vd-actions]'); if (!a) return; + var chip = q('[data-vd-dlchip]'); + var show = dl && ['downloading', 'queued', 'searching', 'completed', 'failed'].indexOf(dl.status) > -1; + if (!show) { if (chip) chip.remove(); return; } + if (!chip) { + chip = document.createElement('button'); + chip.type = 'button'; + chip.setAttribute('data-vd-dlchip', ''); + chip.title = 'Open the Downloads page'; + chip.addEventListener('click', function () { + document.dispatchEvent(new CustomEvent('soulsync:video-navigate', { detail: 'video-downloads' })); + }); + a.parentNode.insertBefore(chip, a); // sits above the action buttons (renderActions won't wipe it) + } + var st = dl.status, pct = Math.max(0, Math.min(100, dl.progress || 0)); + if (st === 'completed') pct = 100; + chip.className = 'vd-dlchip ' + (st === 'completed' ? 'is-done' : (st === 'failed' ? 'is-fail' : 'is-active')); + var label = st === 'completed' ? 'Downloaded' : st === 'failed' ? 'Download failed' + : st === 'searching' ? 'Finding a release…' : st === 'queued' ? 'Queued' : 'Downloading'; + var pctTxt = (st === 'downloading' || st === 'queued') ? ' · ' + pct + '%' : ''; + var ic = st === 'completed' ? '✓ ' : st === 'failed' ? '✕ ' : '⤓ '; + chip.innerHTML = + '' + + '' + ic + esc(label) + pctTxt + ' · Track ↗'; + } + function watchMovieDownload(id) { + stopMovieDownloadWatch(); + _dlWatch.id = id; + (function tick() { + if (currentId !== id || currentKind !== 'movie') return; // navigated away → stop + fetch('/api/video/downloads/status?media_id=' + encodeURIComponent(id) + + '&media_source=' + encodeURIComponent(currentSource || 'library'), + { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (res) { + if (currentId !== id || currentKind !== 'movie') return; + var dl = res && res.download; + renderMovieDownloadChip(dl); + var active = dl && ['downloading', 'queued', 'searching'].indexOf(dl.status) > -1; + if (active) _dlWatch.t = setTimeout(tick, 1800); + }).catch(function () { /* keep last state */ }); + })(); + } + function loadMovie(id, source) { currentKind = 'movie'; currentSource = source || 'library'; if (!root()) return; if (currentId !== id) artAttemptedFor = null; currentId = id; + stopMovieDownloadWatch(); // clear any prior movie's chip showLoading(true); resetExtras(); var dh = q('[data-vd-details]'); if (dh) dh.innerHTML = ''; @@ -1272,6 +1326,7 @@ } else { maybeRefreshMovie(id); loadExtras('movie', id); + watchMovieDownload(id); // live download progress chip (if any) } }) .catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); }); diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index 8e8208c8..07547fa4 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -336,16 +336,16 @@ // Downloads page. Reads the card's row + the panel's search context. function doGrab(btn) { var panel = btn.closest('.vdl-results'); if (!panel || !panel._rows) return; + var card = btn.closest('.vdl-res'); var r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return; - btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…'; + btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); sendGrab(buildGrabPayload(panel, r)).then(function (res) { - btn.classList.remove('vdl-res-grab--busy'); if (res && res.ok) { - btn.textContent = '✓'; btn.classList.add('vdl-res-grab--done'); toast('Sent to Downloads', 'success'); + beginTracking(card, res.id); // selected card → live tracker + Track button document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { - btn.disabled = false; btn.textContent = '⤓'; + btn.disabled = false; btn.classList.remove('vdl-res-grab--busy'); toast((res && res.error) || 'Couldn’t start the download', 'error'); } }); @@ -367,23 +367,25 @@ toast('Auto: no release met your quality profile', 'error'); return; } - // Highlight the card we're auto-grabbing so the choice is transparent. - var cards = panel.querySelectorAll('.vdl-res'); - var card = cards[bestIdx]; - if (card) card.classList.add('vdl-res--auto'); + // Spotlight the card we're auto-grabbing so the choice is obvious, and + // scroll it into view (it may be below the fold among many results). + var card = panel.querySelector('[data-vdl-card="' + bestIdx + '"]'); + if (card) { + card.classList.add('vdl-res--auto'); + if (card.scrollIntoView) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } var gbtn = card && card.querySelector('[data-vdl-grab]'); - if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); gbtn.textContent = '…'; } + if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); } if (statusEl) { statusEl.textContent = 'Auto-grabbing'; statusEl.className = 'vdl-src-status vdl-src-status--scanning'; } - toast('Auto-grabbing best: ' + (best.quality_label || best.title || 'release'), 'info'); + toast('Auto-picked best: ' + (best.quality_label || best.title || 'release'), 'info'); sendGrab(buildGrabPayload(panel, best)).then(function (res) { - if (gbtn) gbtn.classList.remove('vdl-res-grab--busy'); if (res && res.ok) { - if (gbtn) { gbtn.textContent = '✓'; gbtn.classList.add('vdl-res-grab--done'); } if (statusEl) { statusEl.textContent = 'Sent'; statusEl.className = 'vdl-src-status vdl-src-status--done'; } toast('Sent to Downloads', 'success'); + beginTracking(card, res.id); // chosen card → live tracker + Track button document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { - if (gbtn) { gbtn.disabled = false; gbtn.textContent = '⤓'; } + if (gbtn) { gbtn.disabled = false; gbtn.classList.remove('vdl-res-grab--busy'); } if (statusEl) { statusEl.textContent = 'Ready'; statusEl.className = 'vdl-src-status'; } toast((res && res.error) || 'Auto: couldn’t start the download', 'error'); } @@ -408,8 +410,10 @@ return '▲ ' + (r.seeders || 0) + ' seeders'; } - // Readable card: a big resolution tile anchors it, a plain-English quality summary - // leads, the raw release name is demoted to a muted one-liner, then size/seeders. + // Result card: a colour-coded resolution badge anchors the left; the headline is + // a plain-English quality summary with the verdict pill; the raw release name is + // demoted to a mono one-liner; a stat strip (size / uploader / group) sits below. + // The card is a column so a live download tracker can drop in under it on grab. function resultCardHTML(r, i) { var summary = [SRC_LABEL[r.source] || r.source, r.codec ? String(r.codec).toUpperCase() : '', @@ -420,24 +424,99 @@ var verdict = r.accepted ? '✓ Meets profile' : '✕ ' + esc(r.rejected || 'Filtered') + ''; - return '