feat: add typed qBittorrent scheduled polling

This commit is contained in:
Developer
2026-07-14 15:22:34 +00:00
parent eac9b5d33d
commit a9488af0b4
22 changed files with 2036 additions and 56 deletions
+188
View File
@@ -0,0 +1,188 @@
"""Unit tests for typed scheduled actions and scheduler persistence."""
from __future__ import annotations
import threading
import time
from types import SimpleNamespace
from typing import cast
from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.main import app
from media_library_viewer_api.services.qbittorrent_store import (
QBITTORRENT_CONCERN,
QbittorrentSampleStore,
)
from media_library_viewer_api.services.scheduler import ( # type: ignore[reportMissingImports]
Scheduler,
SchedulerBusyError,
)
from media_library_viewer_api.services.scheduler_actions import ( # type: ignore[reportMissingImports]
QBITTORRENT_SPEED_ACTION,
ActionResult,
)
from media_library_viewer_api.services.scheduler_store import ( # type: ignore[reportMissingImports]
SCHEDULER_CONCERN,
SchedulerRunStore,
)
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
from media_library_viewer_api.services.service_data import ServiceDataHarness
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord
@pytest.fixture
def scheduler_client(tmp_path, monkeypatch):
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
reset_encryption_key_cache()
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: store
with patch("media_library_viewer_api.auth.get_settings", return_value=SimpleNamespace(auth_enabled=False)):
yield TestClient(app), store
app.dependency_overrides.clear()
reset_encryption_key_cache()
def _service(service_id: str = "svc-1") -> ServiceRecord:
return ServiceRecord(
id=service_id,
service_type="qbittorrent",
name="qbit",
config={
"poll_interval_seconds": 15,
"sample_retention_seconds": 1_800,
"sample_max_rows": 1200,
},
secrets={},
)
def test_scheduler_routes_expose_status_history_and_disabled_manual_run(scheduler_client):
client, store = scheduler_client
service = store.upsert_service(
{
"service_type": "qbittorrent",
"name": "qbit",
"config": {"base_url": "http://qbit:8080", "polling_enabled": False},
"secrets": {},
"enabled": True,
}
)
status = client.get(f"/api/scheduler/services/{service['id']}/status")
assert status.status_code == 200
assert not status.json()["enabled"]
runs = client.get(f"/api/scheduler/services/{service['id']}/runs")
assert runs.status_code == 200
assert runs.json()["items"] == []
manual = client.post(f"/api/scheduler/services/{service['id']}/run")
assert manual.status_code == 400
def test_sample_store_applies_time_and_row_limits(tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(QBITTORRENT_CONCERN)
harness.run_migrations()
store = QbittorrentSampleStore(harness)
now = round(time.time())
for index in range(70):
store.append(
"svc-1",
now - 20 + index,
index,
index,
retention_seconds=60,
max_rows=60,
)
samples = store.window("svc-1")
assert len(samples) == 60
assert samples[0]["ts"] == now - 10
def test_scheduler_records_success_and_manual_run_resets_backoff(tmp_path, monkeypatch):
harness = ServiceDataHarness(tmp_path)
harness.register(SCHEDULER_CONCERN)
harness.run_migrations()
scheduler = Scheduler()
scheduler._run_store = SchedulerRunStore(harness)
service = _service()
class Action:
def run(self, value):
assert value.id == "svc-1"
return ActionResult(data={"ok": True})
with patch("media_library_viewer_api.services.scheduler.get_scheduled_action", return_value=Action()):
result = scheduler._execute(service, "manual")
assert result["status"] == "success"
fake_store = cast(
SettingsStore,
SimpleNamespace(get_service=lambda service_id: {"id": service_id, "service_type": "qbittorrent", "config": {}}),
)
status = scheduler.status("svc-1", store=fake_store)
assert status["consecutive_failures"] == 0
assert status["backoff_until"] is None
runs, total = scheduler._run_store.list_runs("svc-1", QBITTORRENT_SPEED_ACTION)
assert total == 1
assert runs[0]["trigger"] == "manual"
def test_scheduler_rejects_overlapping_manual_runs(tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(SCHEDULER_CONCERN)
harness.run_migrations()
scheduler = Scheduler()
scheduler._run_store = SchedulerRunStore(harness)
service = _service()
started = threading.Event()
release = threading.Event()
class SlowAction:
def run(self, value):
started.set()
release.wait(2)
return ActionResult(data={})
with patch("media_library_viewer_api.services.scheduler.get_scheduled_action", return_value=SlowAction()):
worker = threading.Thread(target=scheduler._execute, args=(service, "manual"))
worker.start()
assert started.wait(1)
try:
try:
scheduler._execute(service, "manual")
except SchedulerBusyError:
pass
else:
raise AssertionError("expected overlapping run to be rejected")
finally:
release.set()
worker.join(timeout=2)
def test_scheduler_status_marks_never_run_service_stale():
scheduler = Scheduler()
store = cast(
SettingsStore,
SimpleNamespace(
get_service=lambda service_id: {
"id": service_id,
"service_type": "qbittorrent",
"enabled": True,
"config": {},
}
),
)
status = scheduler.status("svc-1", store=store)
assert bool(status["is_stale"])
assert status["poll_interval_seconds"] == 15
+16 -10
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
@@ -22,6 +23,7 @@ from media_library_viewer_api.widgets.sources import (
)
TEST_KEY = Fernet.generate_key().decode()
PROMQL_REQUIRED_ERROR = "promql is required"
@pytest.fixture(autouse=True)
@@ -479,6 +481,7 @@ def test_jellyfin_definition_has_now_playing_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("jellyfin")
assert definition is not None
kinds = {wk.kind for wk in definition.widget_kinds}
assert "now_playing" in kinds
assert "activity" in kinds
@@ -543,7 +546,7 @@ async def test_prometheus_chart_adapter_requires_promql():
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "chart", {"promql": ""})
assert result == {"error": "promql is required"}
assert result == {"error": PROMQL_REQUIRED_ERROR}
@pytest.mark.asyncio
@@ -639,7 +642,7 @@ async def test_jellyfin_activity_shows_all_sessions():
@pytest.fixture
def widget_ref_client(monkeypatch):
def widget_ref_client(monkeypatch, request):
"""TestClient with an isolated SettingsStore + encryption key."""
monkeypatch.setenv(
"MANAGE_ENCRYPTION_KEY",
@@ -659,8 +662,8 @@ def widget_ref_client(monkeypatch):
app.dependency_overrides[get_settings_store] = get_store_override
client = TestClient(app)
yield client, store
app.dependency_overrides.pop(get_settings_store, None)
request.addfinalizer(lambda: app.dependency_overrides.pop(get_settings_store, None))
return client, store
def test_widget_reference_lifecycle(widget_ref_client):
@@ -936,7 +939,7 @@ async def test_prometheus_gauge_adapter_requires_promql():
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "gauge", {"promql": ""})
assert result == {"error": "promql is required"}
assert result == {"error": PROMQL_REQUIRED_ERROR}
@pytest.mark.asyncio
@@ -1038,7 +1041,7 @@ async def test_prometheus_mean_adapter_requires_promql():
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": "promql is required"}
assert result == {"error": PROMQL_REQUIRED_ERROR}
# ---------------------------------------------------------------------------
@@ -1139,8 +1142,8 @@ async def test_qbittorrent_active_filters_dl_ul_only():
@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."""
async def test_qbittorrent_speed_reads_samples_without_polling(tmp_path):
"""Speed kind reads persisted samples and never calls qBittorrent itself."""
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
@@ -1163,15 +1166,18 @@ async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
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
# Wire the mock store to a real isolated store and seed headless data.
real_store = QbittorrentSampleStore(harness)
real_store.append("svc-speed", round(time.time()), 500000, 1000)
mock_store_cls.return_value = real_store
result = await adapter.fetch(service, "speed", {})
mock_client.assert_not_called()
assert "series" in result
labels = [s["label"] for s in result["series"]]
assert labels == ["download", "upload"]
# The sample just appended should be present
# The scheduler-supplied sample should be present.
dl_points = result["series"][0]["points"]
assert len(dl_points) >= 1
# timestamps multiplied by 1000 for JS epoch