feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract

This commit is contained in:
Developer
2026-07-09 08:46:20 +00:00
parent e7bd0afdd1
commit 1fb12b8a0a
14 changed files with 831 additions and 81 deletions
@@ -19,11 +19,13 @@ from typing import Any, Protocol
import requests
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.prometheus_range import (
@@ -368,12 +370,75 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
logger.exception("failed to record ssh task timeout")
class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "qBittorrent widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
username = str(service.secrets.get("username") or "")
password = str(service.secrets.get("password") or "")
timeout = int(service.config.get("timeout_seconds") or 10)
if not base_url or not username or not password:
return {"error": "qBittorrent service is missing base_url, username, or password"}
client = QbittorrentClient(base_url, username, password, timeout)
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
server_state = data.get("server_state", {})
torrents = data.get("torrents", {})
if widget_kind == "totals":
by_state: dict[str, int] = {}
for t in torrents.values():
state = str(t.get("state", "unknown"))
by_state[state] = by_state.get(state, 0) + 1
return {"total": len(torrents), "by_state": by_state}
if widget_kind == "active":
active = [
{
"name": t.get("name"),
"state": t.get("state"),
"size": t.get("size"),
"progress": t.get("progress"),
"dl_speed": t.get("dlspeed"),
"up_speed": t.get("upspeed"),
}
for t in torrents.values()
if str(t.get("state", "")) in {"downloading", "uploading"}
]
return {"torrents": active}
if widget_kind == "speed":
dl = int(server_state.get("dl_info_speed", 0))
up = int(server_state.get("up_info_speed", 0))
ts = int(time.time())
store = QbittorrentSampleStore()
store.append(service.id, ts, dl, up)
samples = store.window(service.id)
series = [
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
]
return {"series": series}
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
except asyncio.TimeoutError:
return {"error": "qBittorrent data fetch timed out"}
except Exception as exc:
logger.exception("qbittorrent adapter failed")
return {"error": f"qBittorrent fetch failed: {exc}"}
# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"prometheus": PrometheusWidgetSource(),
"qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(),
+187
View File
@@ -988,3 +988,190 @@ async def test_prometheus_mean_adapter_requires_promql():
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": "promql is required"}
# ---------------------------------------------------------------------------
# qBittorrent widget source adapter
# ---------------------------------------------------------------------------
def _fake_qbit_maindata():
"""Return a mock maindata response (server_state + torrents dict)."""
return {
"server_state": {"dl_info_speed": 500000, "up_info_speed": 100000},
"torrents": {
"h1": {
"name": "Movie.mkv",
"state": "downloading",
"size": 1000,
"progress": 0.5,
"dlspeed": 500,
"upspeed": 10,
},
"h2": {
"name": "Show.mkv",
"state": "uploading",
"size": 2000,
"progress": 1.0,
"dlspeed": 0,
"upspeed": 100,
},
"h3": {
"name": "Queued",
"state": "queuedDL",
"size": 3000,
"progress": 0.0,
"dlspeed": 0,
"upspeed": 0,
},
"h4": {
"name": "Paused",
"state": "pausedDL",
"size": 4000,
"progress": 0.3,
"dlspeed": 0,
"upspeed": 0,
},
},
}
@pytest.mark.asyncio
async def test_qbittorrent_totals_counts_all_torrents():
"""Totals kind returns count of all listed items + by_state breakdown."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "totals", {})
assert result["total"] == 4
assert result["by_state"]["downloading"] == 1
assert result["by_state"]["uploading"] == 1
assert result["by_state"]["queuedDL"] == 1
assert result["by_state"]["pausedDL"] == 1
@pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only():
"""Active kind returns only downloading/uploading torrents (Q3)."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "active", {})
active = result["torrents"]
assert len(active) == 2
names = [t["name"] for t in active]
assert "Movie.mkv" in names
assert "Show.mkv" in names
# Queued and paused are excluded
assert "Queued" not in names
assert "Paused" not in names
@pytest.mark.asyncio
async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
"""Speed kind appends a sample and returns {series} with two labeled series."""
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
from media_library_viewer_api.services.service_data import ServiceDataHarness
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
# Isolated harness so we don't pollute the real DB
harness = ServiceDataHarness(base_dir=str(tmp_path))
harness.register(QBITTORRENT_CONCERN)
harness.run_migrations()
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-speed",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with (
patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client,
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
):
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
# Wire the mock store to a real isolated store
real_store = QbittorrentSampleStore(harness)
mock_store_cls.return_value = real_store
result = await adapter.fetch(service, "speed", {})
assert "series" in result
labels = [s["label"] for s in result["series"]]
assert labels == ["download", "upload"]
# The sample just appended should be present
dl_points = result["series"][0]["points"]
assert len(dl_points) >= 1
# timestamps multiplied by 1000 for JS epoch
assert dl_points[-1]["v"] == 500000
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_service():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
result = await adapter.fetch(None, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_credentials():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080"},
secrets={"username": "", "password": ""},
)
result = await adapter.fetch(service, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_timeout():
"""A timeout returns {error} rather than raising."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 1},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
import asyncio as _asyncio
async def _slow(*a, **kw):
await _asyncio.sleep(10)
# Make to_thread hang so wait_for times out
mock_client.return_value.maindata.side_effect = lambda: (_ for _ in ()).throw(TimeoutError())
result = await adapter.fetch(service, "totals", {})
assert "error" in result