video side: live dashboard via isolated /api/video blueprint

First wire from video.db -> UI, kettui-style.
- api/video/ : isolated Flask blueprint (registered at /api/video with one
  additive line in web_server.py). Reads only video.db; imports nothing from
  the music API or DB.
- GET /api/video/dashboard -> VideoDatabase.dashboard_stats(): live library/
  download/watchlist/wishlist counts (real 0s on an empty DB).
- video-dashboard.js now fetches it and fills the stat cards + Watchlist/
  Wishlist header badges (formatted bytes/speed); falls back to zeros on error.
  uptime/memory stay at markup defaults for now (not video-domain).
- Tests: dashboard_stats counts (empty + populated), endpoint returns zeroed
  JSON via a Flask test client, blueprint exposes the route, and the video API
  imports nothing from music. 93 video/integrity tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-13 22:40:48 -07:00
parent 402a1fec50
commit 401a9be0ec
7 changed files with 232 additions and 7 deletions

43
api/video/__init__.py Normal file
View file

@ -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

24
api/video/dashboard.py Normal file
View file

@ -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

View file

@ -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."""

51
tests/test_video_api.py Normal file
View file

@ -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}"

View file

@ -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")

View file

@ -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.

View file

@ -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