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:
parent
402a1fec50
commit
401a9be0ec
7 changed files with 232 additions and 7 deletions
43
api/video/__init__.py
Normal file
43
api/video/__init__.py
Normal 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
24
api/video/dashboard.py
Normal 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
|
||||||
|
|
@ -127,6 +127,40 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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 ───────────────────────────────────────────────────────────────
|
# ── health ───────────────────────────────────────────────────────────────
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
"""True when the DB opens and passes a quick integrity check."""
|
"""True when the DB opens and passes a quick integrity check."""
|
||||||
|
|
|
||||||
51
tests/test_video_api.py
Normal file
51
tests/test_video_api.py
Normal 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}"
|
||||||
|
|
@ -138,6 +138,30 @@ def test_wishlist_view_is_wanted_but_missing(db):
|
||||||
assert ("episode", 2) not in rows # future episode absent
|
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):
|
def test_settings_kv_roundtrip(db):
|
||||||
assert db.get_setting("download_dir", "unset") == "unset"
|
assert db.get_setting("download_dir", "unset") == "unset"
|
||||||
db.set_setting("download_dir", "/data/video")
|
db.set_setting("download_dir", "/data/video")
|
||||||
|
|
|
||||||
|
|
@ -37110,6 +37110,10 @@ _configure_enrichment_api(
|
||||||
|
|
||||||
app.register_blueprint(_create_enrichment_blueprint())
|
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():
|
def _emit_rate_monitor_loop():
|
||||||
"""Background thread that pushes API call rate data every 1 second for speedometer gauges.
|
"""Background thread that pushes API call rate data every 1 second for speedometer gauges.
|
||||||
|
|
|
||||||
|
|
@ -21,21 +21,49 @@
|
||||||
|
|
||||||
var DASHBOARD_ID = 'video-dashboard';
|
var DASHBOARD_ID = 'video-dashboard';
|
||||||
|
|
||||||
// Zeroed placeholder until the video DB is wired. Keys match the markup's
|
var DASHBOARD_URL = '/api/video/dashboard';
|
||||||
// data-video-stat attributes.
|
|
||||||
var STUB_STATS = {
|
// 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',
|
'active-downloads': '0',
|
||||||
'finished-downloads': '0',
|
'finished-downloads': '0',
|
||||||
'download-speed': '0 KB/s',
|
'download-speed': '0 KB/s',
|
||||||
'disk-usage': '--',
|
'disk-usage': '--',
|
||||||
'uptime': '0m',
|
|
||||||
'memory': '--',
|
|
||||||
'movies': '0',
|
'movies': '0',
|
||||||
'shows': '0',
|
'shows': '0',
|
||||||
'episodes': '0',
|
'episodes': '0',
|
||||||
'library-size': '--'
|
'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) {
|
function applyStats(stats) {
|
||||||
var nodes = document.querySelectorAll('[data-video-stat]');
|
var nodes = document.querySelectorAll('[data-video-stat]');
|
||||||
for (var i = 0; i < nodes.length; i++) {
|
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() {
|
function loadStats() {
|
||||||
// TODO(video.db): replace with fetch('/api/video/dashboard') -> applyStats(json).
|
fetch(DASHBOARD_URL, { headers: { 'Accept': 'application/json' } })
|
||||||
applyStats(STUB_STATS);
|
.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
|
// Service "Test" buttons are inert until the video services exist. Mark them
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue