diff --git a/api/video/__init__.py b/api/video/__init__.py new file mode 100644 index 00000000..d5053947 --- /dev/null +++ b/api/video/__init__.py @@ -0,0 +1,43 @@ +"""SoulSync — VIDEO side API package (isolated). + +A SEPARATE Flask blueprint from the music API (api_v1). It reads only +database/video_library.db via VideoDatabase and imports nothing from the music +API or music database layer. Registered in web_server.py with a single additive +line at url_prefix '/api/video', so music routing is untouched. +""" + +from __future__ import annotations + +import threading + +from flask import Blueprint + +from utils.logging_config import get_logger + +logger = get_logger("video_api") + +# Lazily-created, process-wide VideoDatabase handle. VideoDatabase itself guards +# schema init once-per-path, so this just avoids re-opening the wrapper. +_video_db = None +_video_db_lock = threading.Lock() + + +def get_video_db(): + """Return the shared VideoDatabase instance (created on first use).""" + global _video_db + if _video_db is None: + with _video_db_lock: + if _video_db is None: + from database.video_database import VideoDatabase + _video_db = VideoDatabase() + return _video_db + + +def create_video_blueprint() -> Blueprint: + """Build the isolated /api/video blueprint with all video sub-routes.""" + bp = Blueprint("video_api", __name__) + + from .dashboard import register_routes as reg_dashboard + reg_dashboard(bp) + + return bp diff --git a/api/video/dashboard.py b/api/video/dashboard.py new file mode 100644 index 00000000..77201ac8 --- /dev/null +++ b/api/video/dashboard.py @@ -0,0 +1,24 @@ +"""Video dashboard endpoint — live counts from video.db. + +GET /api/video/dashboard -> {library:{...}, downloads:{...}, watchlist, wishlist} +With an empty database every value is a real 0. +""" + +from __future__ import annotations + +from flask import jsonify + +from utils.logging_config import get_logger + +logger = get_logger("video_api.dashboard") + + +def register_routes(bp): + @bp.route("/dashboard", methods=["GET"]) + def video_dashboard(): + from . import get_video_db + try: + return jsonify(get_video_db().dashboard_stats()) + except Exception: + logger.exception("Failed to build video dashboard stats") + return jsonify({"error": "Failed to load video dashboard stats"}), 500 diff --git a/database/video_database.py b/database/video_database.py index 507dd7b9..44637028 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -127,6 +127,40 @@ class VideoDatabase: finally: conn.close() + # ── dashboard ───────────────────────────────────────────────────────────── + def dashboard_stats(self) -> dict: + """Live counts for the video dashboard, straight from video.db. + + Shape is stable so the frontend can map it directly; with an empty + database every number is a real 0 (not a stub). + """ + conn = self._get_connection() + try: + def scalar(sql: str): + return conn.execute(sql).fetchone()[0] + + return { + "library": { + "movies": scalar("SELECT COUNT(*) FROM movies"), + "shows": scalar("SELECT COUNT(*) FROM shows"), + "episodes": scalar("SELECT COUNT(*) FROM episodes"), + "size_bytes": scalar("SELECT COALESCE(SUM(size_bytes), 0) FROM media_files"), + }, + "downloads": { + "active": scalar( + "SELECT COUNT(*) FROM downloads " + "WHERE status IN ('queued','downloading','importing')"), + "finished": scalar("SELECT COUNT(*) FROM downloads WHERE status = 'completed'"), + "speed_bps": scalar( + "SELECT COALESCE(SUM(download_speed_bps), 0) FROM downloads " + "WHERE status = 'downloading'"), + }, + "watchlist": scalar("SELECT COUNT(*) FROM v_watchlist"), + "wishlist": scalar("SELECT COUNT(*) FROM v_wishlist"), + } + finally: + conn.close() + # ── health ─────────────────────────────────────────────────────────────── def health_check(self) -> bool: """True when the DB opens and passes a quick integrity check.""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py new file mode 100644 index 00000000..9206f243 --- /dev/null +++ b/tests/test_video_api.py @@ -0,0 +1,51 @@ +"""Seam tests for the isolated /api/video blueprint (experimental branch). + +Verifies the blueprint builds with its route, the dashboard endpoint returns +real (zeroed) JSON against an empty video.db, and that the video API package +imports nothing from the music side. +""" + +from __future__ import annotations + +from pathlib import Path + +from flask import Flask + + +def _make_client(tmp_path, monkeypatch): + monkeypatch.setenv("VIDEO_DATABASE_PATH", str(tmp_path / "video_library.db")) + import api.video as videoapi + videoapi._video_db = None # drop any cached handle so the env path is used + app = Flask(__name__) + app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") + return app.test_client(), videoapi + + +def test_blueprint_exposes_dashboard_route(): + from api.video import create_video_blueprint + app = Flask(__name__) + app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") + rules = {r.rule for r in app.url_map.iter_rules()} + assert "/api/video/dashboard" in rules + + +def test_dashboard_endpoint_returns_zeroed_json(tmp_path, monkeypatch): + client, videoapi = _make_client(tmp_path, monkeypatch) + try: + resp = client.get("/api/video/dashboard") + assert resp.status_code == 200 + data = resp.get_json() + assert data["library"]["movies"] == 0 + assert data["downloads"]["active"] == 0 + assert data["watchlist"] == 0 and data["wishlist"] == 0 + finally: + videoapi._video_db = None # don't leak the tmp DB to other tests + + +def test_video_api_imports_nothing_from_music(): + base = Path(__file__).resolve().parent.parent / "api" / "video" + for py in base.glob("*.py"): + for line in py.read_text(encoding="utf-8").splitlines(): + s = line.strip() + if s.startswith("import ") or s.startswith("from "): + assert "music" not in s.lower(), f"{py.name}: music import leaked: {s!r}" diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 88c71804..24838a0e 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -138,6 +138,30 @@ def test_wishlist_view_is_wanted_but_missing(db): assert ("episode", 2) not in rows # future episode absent +def test_dashboard_stats_empty_is_all_zero(db): + s = db.dashboard_stats() + assert s["library"] == {"movies": 0, "shows": 0, "episodes": 0, "size_bytes": 0} + assert s["downloads"] == {"active": 0, "finished": 0, "speed_bps": 0} + assert s["watchlist"] == 0 and s["wishlist"] == 0 + + +def test_dashboard_stats_counts_content_and_downloads(db): + with db.connect() as conn: + conn.execute("INSERT INTO movies(id,title,monitored,has_file) VALUES (1,'M',1,0)") + conn.execute("INSERT INTO shows(id,title,monitored) VALUES (1,'S',1)") + conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)") + conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number) " + "VALUES (1,1,1,1,1)") + conn.execute("INSERT INTO media_files(movie_id,relative_path,size_bytes) VALUES (1,'m.mkv',1000)") + conn.execute("INSERT INTO downloads(movie_id,title,status,download_speed_bps) " + "VALUES (1,'d','downloading',500)") + conn.commit() + s = db.dashboard_stats() + assert s["library"] == {"movies": 1, "shows": 1, "episodes": 1, "size_bytes": 1000} + assert s["downloads"]["active"] == 1 and s["downloads"]["speed_bps"] == 500 + assert s["watchlist"] == 1 and s["wishlist"] == 1 + + def test_settings_kv_roundtrip(db): assert db.get_setting("download_dir", "unset") == "unset" db.set_setting("download_dir", "/data/video") diff --git a/web_server.py b/web_server.py index fd53c71a..dc3b666c 100644 --- a/web_server.py +++ b/web_server.py @@ -37110,6 +37110,10 @@ _configure_enrichment_api( app.register_blueprint(_create_enrichment_blueprint()) +# Video side API (isolated: reads database/video_library.db only, never music) +from api.video import create_video_blueprint as _create_video_blueprint +app.register_blueprint(_create_video_blueprint(), url_prefix='/api/video') + def _emit_rate_monitor_loop(): """Background thread that pushes API call rate data every 1 second for speedometer gauges. diff --git a/webui/static/video/video-dashboard.js b/webui/static/video/video-dashboard.js index 16642a76..ecd8169d 100644 --- a/webui/static/video/video-dashboard.js +++ b/webui/static/video/video-dashboard.js @@ -21,21 +21,49 @@ var DASHBOARD_ID = 'video-dashboard'; - // Zeroed placeholder until the video DB is wired. Keys match the markup's - // data-video-stat attributes. - var STUB_STATS = { + var DASHBOARD_URL = '/api/video/dashboard'; + + // Fallback only — shown if the API call fails. (uptime/memory aren't in the + // video payload yet; they stay at their markup defaults for now.) + var FALLBACK_STATS = { 'active-downloads': '0', 'finished-downloads': '0', 'download-speed': '0 KB/s', 'disk-usage': '--', - 'uptime': '0m', - 'memory': '--', 'movies': '0', 'shows': '0', 'episodes': '0', 'library-size': '--' }; + function formatBytes(n) { + n = Number(n) || 0; + if (n <= 0) return '0 B'; + var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + var i = Math.floor(Math.log(n) / Math.log(1024)); + if (i >= units.length) i = units.length - 1; + return (n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; + } + + function formatSpeed(bps) { + return formatBytes(bps) + '/s'; + } + + // Map the API payload onto the flat data-video-stat keys in the markup. + function flatten(d) { + var lib = d.library || {}, dl = d.downloads || {}; + return { + 'active-downloads': String(dl.active != null ? dl.active : 0), + 'finished-downloads': String(dl.finished != null ? dl.finished : 0), + 'download-speed': formatSpeed(dl.speed_bps), + 'disk-usage': formatBytes(lib.size_bytes), + 'movies': String(lib.movies != null ? lib.movies : 0), + 'shows': String(lib.shows != null ? lib.shows : 0), + 'episodes': String(lib.episodes != null ? lib.episodes : 0), + 'library-size': formatBytes(lib.size_bytes) + }; + } + function applyStats(stats) { var nodes = document.querySelectorAll('[data-video-stat]'); for (var i = 0; i < nodes.length; i++) { @@ -46,9 +74,26 @@ } } + function applyBadges(d) { + var nodes = document.querySelectorAll('[data-video-badge]'); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute('data-video-badge'); + if (d[key] != null) nodes[i].textContent = String(d[key]); + } + } + function loadStats() { - // TODO(video.db): replace with fetch('/api/video/dashboard') -> applyStats(json). - applyStats(STUB_STATS); + fetch(DASHBOARD_URL, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (d && !d.error) { + applyStats(flatten(d)); + applyBadges(d); + } else { + applyStats(FALLBACK_STATS); + } + }) + .catch(function () { applyStats(FALLBACK_STATS); }); } // Service "Test" buttons are inert until the video services exist. Mark them