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:
|
||||
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
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
|
||||
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue