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
@@ -9,6 +9,7 @@ the first real consumer of the harness lifecycle layer.
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.services.service_data import StorageConcern
@@ -37,8 +38,17 @@ QBITTORRENT_CONCERN = StorageConcern(
tables=["qbittorrent_speed_samples"],
)
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
MAX_SAMPLES = 120
#: Maximum samples kept per service. The scheduler may choose a lower cap.
MAX_SAMPLES = 1_200
MIN_SAMPLE_ROWS = 60
DEFAULT_RETENTION_SECONDS = 1_800
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
class QbittorrentSampleStore:
@@ -56,22 +66,53 @@ class QbittorrentSampleStore:
harness = get_service_data_harness()
self._harness = harness
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
def append(
self,
service_id: str,
ts: int,
dl_speed: int,
up_speed: int,
*,
retention_seconds: int | None = None,
max_rows: int = MAX_SAMPLES,
) -> None:
"""Append a sample and prune by the configured time and row limits."""
max_rows = max(MIN_SAMPLE_ROWS, min(_safe_int(max_rows, MAX_SAMPLES), MAX_SAMPLES))
cutoff = None
if retention_seconds is not None:
retention = max(MIN_SAMPLE_ROWS, _safe_int(retention_seconds, DEFAULT_RETENTION_SECONDS))
cutoff = _safe_int(time.time()) - retention
with self._harness.connect("qbittorrent") as conn:
conn.execute(
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
(service_id, ts, dl_speed, up_speed),
)
conn.execute(
"DELETE FROM qbittorrent_speed_samples "
"WHERE service_id = ? AND ts NOT IN ("
" SELECT ts FROM qbittorrent_speed_samples"
" WHERE service_id = ?"
" ORDER BY ts DESC LIMIT ?"
")",
(service_id, service_id, MAX_SAMPLES),
(service_id, _safe_int(ts), _safe_int(dl_speed), _safe_int(up_speed)),
)
if cutoff is None:
conn.execute(
"""
DELETE FROM qbittorrent_speed_samples
WHERE service_id = ? AND rowid NOT IN (
SELECT rowid FROM qbittorrent_speed_samples
WHERE service_id = ?
ORDER BY ts DESC, rowid DESC LIMIT ?
)
""",
(service_id, service_id, max_rows),
)
else:
conn.execute(
"""
DELETE FROM qbittorrent_speed_samples
WHERE service_id = ? AND (
ts < ? OR rowid NOT IN (
SELECT rowid FROM qbittorrent_speed_samples
WHERE service_id = ?
ORDER BY ts DESC, rowid DESC LIMIT ?
)
)
""",
(service_id, cutoff, service_id, max_rows),
)
conn.commit()
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
@@ -0,0 +1,296 @@
"""Single-worker scheduler for typed backend actions."""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any
from media_library_viewer_api.observability import record_scheduled_action
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
from .scheduler_actions import ( # type: ignore[reportMissingImports]
QBITTORRENT_SPEED_ACTION,
get_scheduled_action,
)
from .scheduler_store import SchedulerRunStore # type: ignore[reportMissingImports]
from .settings_store import SettingsStore, get_settings_store
logger = logging.getLogger(__name__)
DEFAULT_INTERVAL_SECONDS = 15
MIN_INTERVAL_SECONDS = 5
MAX_INTERVAL_SECONDS = 300
DEFAULT_RETENTION_SECONDS = 1_800
MAX_RETENTION_SECONDS = 86_400
DEFAULT_MAX_ROWS = 1_200
MAX_MAX_ROWS = 1_200
SCHEDULER_LOOP_SECONDS = 1.0
BACKOFF_CAP_SECONDS = 300
class SchedulerBusyError(RuntimeError):
"""Raised when a manual action overlaps an existing service run."""
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _bounded_config(config: dict[str, Any]) -> tuple[int, int, int]:
interval = max(
MIN_INTERVAL_SECONDS,
min(_safe_int(config.get("poll_interval_seconds"), DEFAULT_INTERVAL_SECONDS), MAX_INTERVAL_SECONDS),
)
retention = max(
60,
min(_safe_int(config.get("sample_retention_seconds"), DEFAULT_RETENTION_SECONDS), MAX_RETENTION_SECONDS),
)
max_rows = max(60, min(_safe_int(config.get("sample_max_rows"), DEFAULT_MAX_ROWS), MAX_MAX_ROWS))
return interval, retention, max_rows
@dataclass
class _ServiceState:
signature: tuple[Any, ...]
next_run_at: float | None = None
running: bool = False
last_attempt_at: int | None = None
last_success_at: int | None = None
last_error: str = ""
consecutive_failures: int = 0
backoff_until: int | None = None
class Scheduler:
"""Run registered service actions from one lifespan-managed worker."""
action_key = QBITTORRENT_SPEED_ACTION
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._state_lock = threading.RLock()
self._service_locks: dict[str, threading.Lock] = {}
self._states: dict[str, _ServiceState] = {}
self._run_store = SchedulerRunStore()
def start(self) -> None:
with self._state_lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="scheduled-actions", daemon=True)
self._thread.start()
logger.info("Scheduled action worker started")
def stop(self, timeout: float = 5.0) -> None:
with self._state_lock:
thread = self._thread
if not thread:
return
self._stop_event.set()
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning("Scheduled action worker did not stop within %.1fs", timeout)
else:
logger.info("Scheduled action worker stopped")
def status(self, service_id: str, store: SettingsStore | None = None) -> dict[str, Any]:
store = store or get_settings_store()
service = store.get_service(service_id)
if not service or service.get("service_type") != "qbittorrent":
raise ValueError("qBittorrent service not found")
config = service.get("config") or {}
interval, retention, max_rows = _bounded_config(config)
with self._state_lock:
state = self._states.get(service_id)
worker_running = bool(self._thread and self._thread.is_alive())
if state is None:
state = _ServiceState(signature=())
last_success = state.last_success_at
is_stale = last_success is None or time.time() - last_success > max(2 * interval, 60)
return {
"service_id": service_id,
"action_key": self.action_key,
"worker_running": worker_running,
"enabled": bool(service.get("enabled", True)) and bool(config.get("polling_enabled", True)),
"running": state.running,
"poll_interval_seconds": interval,
"sample_retention_seconds": retention,
"sample_max_rows": max_rows,
"next_run_at": _safe_int(state.next_run_at) if state.next_run_at is not None else None,
"last_attempt_at": state.last_attempt_at,
"last_success_at": last_success,
"last_error": state.last_error,
"consecutive_failures": state.consecutive_failures,
"backoff_until": state.backoff_until,
"is_stale": is_stale,
}
def run_now(self, service_id: str, store: SettingsStore | None = None) -> dict[str, Any]:
store = store or get_settings_store()
service_row = store.get_service(service_id)
if not service_row or service_row.get("service_type") != "qbittorrent":
raise ValueError("qBittorrent service not found")
if not service_row.get("enabled", True):
raise ValueError("Service is disabled")
config = service_row.get("config") or {}
if not config.get("polling_enabled", True):
raise ValueError("Polling is disabled")
service = build_service_record(store, service_row)
run = self._execute(service, "manual")
return {"run": run, "status": self.status(service_id, store)}
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._reconcile_and_run()
except Exception:
logger.exception("Scheduled action cycle failed")
self._stop_event.wait(SCHEDULER_LOOP_SECONDS)
def _reconcile_and_run(self) -> None:
store = get_settings_store()
services = sorted(store.list_services("qbittorrent"), key=lambda row: str(row.get("id") or ""))
active_ids = {str(row.get("id") or "") for row in services}
with self._state_lock:
for service_id in set(self._states) - active_ids:
self._states.pop(service_id, None)
self._service_locks.pop(service_id, None)
now = time.time()
for index, service_row in enumerate(services):
service_id = str(service_row.get("id") or "")
if not service_id:
continue
config = service_row.get("config") or {}
interval, _, _ = _bounded_config(config)
enabled = bool(service_row.get("enabled", True)) and bool(config.get("polling_enabled", True))
signature = (enabled, interval, config.get("sample_retention_seconds"), config.get("sample_max_rows"))
with self._state_lock:
state = self._states.get(service_id)
if state is None:
state = _ServiceState(signature=signature, next_run_at=now + min(index * 0.5, 5.0))
self._states[service_id] = state
elif state.signature != signature:
state.signature = signature
state.next_run_at = now if enabled else None
if not enabled:
state.next_run_at = None
continue
due = state.next_run_at is not None and now >= state.next_run_at
if due:
service = build_service_record(store, service_row)
try:
self._execute(service, "schedule")
except SchedulerBusyError:
logger.debug("Scheduled action already running service_id=%s", service_id)
with self._state_lock:
state = self._states.get(service_id)
if state:
delay = interval
if state.backoff_until:
delay = max(delay, state.backoff_until - _safe_int(time.time()))
state.next_run_at = time.time() + max(1, delay)
def _execute(self, service: ServiceRecord, trigger: str) -> dict[str, Any]:
lock = self._service_lock(service.id)
if not lock.acquire(blocking=False):
raise SchedulerBusyError(f"Action already running for service {service.id}")
try:
return self._execute_locked(service, trigger)
finally:
lock.release()
def _execute_locked(self, service: ServiceRecord, trigger: str) -> dict[str, Any]:
config = service.config
interval, _, _ = _bounded_config(config)
state = self._state_for(service.id, config)
attempt = state.consecutive_failures
start = time.perf_counter()
run = self._run_store.start_run(service.id, self.action_key, trigger, attempt=attempt)
with self._state_lock:
state.running = True
state.last_attempt_at = _safe_int(time.time())
action = get_scheduled_action(self.action_key)
try:
if action is None:
raise RuntimeError(f"Scheduled action is not registered: {self.action_key}")
action.run(service)
except Exception as exc:
duration_ms = _safe_int((time.perf_counter() - start) * 1000)
error = str(exc)[:1000]
finished = self._run_store.finish_run(run["id"], "failure", duration_ms=duration_ms, error=error)
self._run_store.prune(service.id, self.action_key)
with self._state_lock:
state.running = False
state.last_error = error
state.consecutive_failures += 1
delay = min(BACKOFF_CAP_SECONDS, max(interval, 2**state.consecutive_failures))
state.backoff_until = _safe_int(time.time()) + delay
record_scheduled_action(
service.id,
self.action_key,
"failure",
duration_seconds=duration_ms / 1000.0,
consecutive_failures=state.consecutive_failures,
)
logger.warning("Scheduled action failed service_id=%s action=%s: %s", service.id, self.action_key, error)
return finished if finished is not None else run
duration_ms = _safe_int((time.perf_counter() - start) * 1000)
finished = self._run_store.finish_run(run["id"], "success", duration_ms=duration_ms)
self._run_store.prune(service.id, self.action_key)
with self._state_lock:
state.running = False
state.last_success_at = _safe_int(time.time())
state.last_error = ""
state.consecutive_failures = 0
state.backoff_until = None
if trigger == "manual":
state.next_run_at = max(state.next_run_at or 0, time.time() + interval)
record_scheduled_action(
service.id,
self.action_key,
"success",
duration_seconds=duration_ms / 1000.0,
success=True,
consecutive_failures=0,
)
return finished if finished is not None else run
def _state_for(self, service_id: str, config: dict[str, Any]) -> _ServiceState:
with self._state_lock:
state = self._states.get(service_id)
if state is None:
interval, _, _ = _bounded_config(config)
state = _ServiceState(signature=(), next_run_at=time.time() + interval)
self._states[service_id] = state
return state
def _service_lock(self, service_id: str) -> threading.Lock:
with self._state_lock:
return self._service_locks.setdefault(service_id, threading.Lock())
_SCHEDULER = Scheduler()
def get_scheduler() -> Scheduler:
return _SCHEDULER
def reset_scheduler() -> None:
"""Reset the singleton for tests."""
global _SCHEDULER
_SCHEDULER.stop()
_SCHEDULER = Scheduler()
__all__ = ["Scheduler", "SchedulerBusyError", "get_scheduler", "reset_scheduler"]
@@ -0,0 +1,83 @@
"""Typed scheduled-action registry and qBittorrent sampling action."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Protocol
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.widgets.sources import ServiceRecord, _qbittorrent_client
QBITTORRENT_SPEED_ACTION = "qbittorrent.speed_sample"
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
@dataclass(frozen=True)
class ActionResult:
data: dict[str, Any]
class ScheduledAction(Protocol):
action_key: str
def run(self, service: ServiceRecord) -> ActionResult:
"""Run one action for one service instance."""
return ActionResult(data={})
class QbittorrentSpeedAction:
action_key = QBITTORRENT_SPEED_ACTION
def run(self, service: ServiceRecord) -> ActionResult:
base_url = str(service.config.get("base_url") or "")
username = str(service.secrets.get("username") or "")
password = str(service.secrets.get("password") or "")
timeout = _safe_int(service.config.get("timeout_seconds") or 60, 60)
if not base_url or not username or not password:
raise ValueError("qBittorrent service is missing base_url, username, or password")
client = _qbittorrent_client((service.id, base_url, username, password, timeout))
payload = client.maindata()
server_state = payload.get("server_state", {})
dl_speed = _safe_int(server_state.get("dl_info_speed", 0) or 0)
up_speed = _safe_int(server_state.get("up_info_speed", 0) or 0)
ts = _safe_int(time.time())
store = QbittorrentSampleStore()
store.append(
service.id,
ts,
dl_speed,
up_speed,
retention_seconds=_safe_int(service.config.get("sample_retention_seconds") or 1800, 1800),
max_rows=_safe_int(service.config.get("sample_max_rows") or 1200, 1200),
)
return ActionResult(data={"ts": ts, "dl_speed": dl_speed, "up_speed": up_speed})
_ACTIONS: dict[str, ScheduledAction] = {
QBITTORRENT_SPEED_ACTION: QbittorrentSpeedAction(),
}
def get_scheduled_action(action_key: str) -> ScheduledAction | None:
return _ACTIONS.get(action_key)
def list_scheduled_actions() -> list[str]:
return sorted(_ACTIONS)
__all__ = [
"ActionResult",
"QBITTORRENT_SPEED_ACTION",
"ScheduledAction",
"get_scheduled_action",
"list_scheduled_actions",
]
@@ -0,0 +1,181 @@
"""Persistence for typed scheduled-action execution history."""
from __future__ import annotations
import time
import uuid
from typing import Any
from media_library_viewer_api.services.service_data import ServiceDataHarness, StorageConcern
SCHEDULER_CONCERN = StorageConcern(
concern_key="scheduler",
db_filename="scheduler.db",
migrations=[
"""
CREATE TABLE IF NOT EXISTS scheduler_action_runs (
id TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
action_key TEXT NOT NULL,
trigger TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_service_action_started
ON scheduler_action_runs(service_id, action_key, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_status_started
ON scheduler_action_runs(status, started_at DESC);
"""
],
tables=["scheduler_action_runs"],
)
MAX_RUNS_PER_ACTION = 1_000
RUN_RETENTION_SECONDS = 30 * 24 * 60 * 60
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
class SchedulerRunStore:
"""Store scheduler run records in the scheduler service-data concern."""
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
if harness is None:
from media_library_viewer_api.services.service_data import get_service_data_harness
harness = get_service_data_harness()
self._harness = harness
def start_run(self, service_id: str, action_key: str, trigger: str, attempt: int = 0) -> dict[str, Any]:
now = _safe_int(time.time())
run = {
"id": uuid.uuid4().hex[:12],
"service_id": service_id,
"action_key": action_key,
"trigger": trigger,
"started_at": now,
"finished_at": None,
"status": "running",
"attempt": _safe_int(attempt),
"duration_ms": None,
"error": "",
"created_at": now,
}
with self._harness.connect("scheduler") as conn:
conn.execute(
"""
INSERT INTO scheduler_action_runs
(id, service_id, action_key, trigger, started_at, finished_at,
status, attempt, duration_ms, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run["id"],
run["service_id"],
run["action_key"],
run["trigger"],
run["started_at"],
run["finished_at"],
run["status"],
run["attempt"],
run["duration_ms"],
run["error"],
run["created_at"],
),
)
conn.commit()
return run
def finish_run(
self,
run_id: str,
status: str,
finished_at: int | None = None,
duration_ms: int | None = None,
error: str = "",
) -> dict[str, Any] | None:
finished_at = _safe_int(finished_at if finished_at is not None else time.time())
with self._harness.connect("scheduler") as conn:
conn.execute(
"""
UPDATE scheduler_action_runs
SET finished_at = ?, status = ?, duration_ms = ?, error = ?
WHERE id = ?
""",
(finished_at, status, duration_ms, str(error or "")[:1000], run_id),
)
conn.commit()
row = conn.execute("SELECT * FROM scheduler_action_runs WHERE id = ?", (run_id,)).fetchone()
return dict(row) if row else None
def get_run(self, run_id: str) -> dict[str, Any] | None:
with self._harness.connect("scheduler") as conn:
row = conn.execute("SELECT * FROM scheduler_action_runs WHERE id = ?", (run_id,)).fetchone()
return dict(row) if row else None
def list_runs(
self,
service_id: str,
action_key: str,
*,
status: str | None = None,
trigger: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
limit = max(1, min(_safe_int(limit, 50), 100))
offset = max(0, _safe_int(offset))
with self._harness.connect("scheduler") as conn:
rows = conn.execute(
"""
SELECT * FROM scheduler_action_runs
WHERE service_id = ? AND action_key = ?
ORDER BY started_at DESC, id DESC LIMIT 1000
""",
(service_id, action_key),
).fetchall()
filtered = [
dict(row)
for row in rows
if (not status or row["status"] == status) and (not trigger or row["trigger"] == trigger)
]
return filtered[offset : offset + limit], len(filtered)
def prune(self, service_id: str, action_key: str, now: int | None = None) -> int:
now = _safe_int(now if now is not None else time.time())
cutoff = now - RUN_RETENTION_SECONDS
with self._harness.connect("scheduler") as conn:
cursor = conn.execute(
"""
DELETE FROM scheduler_action_runs
WHERE service_id = ? AND action_key = ?
AND (
created_at < ? OR rowid NOT IN (
SELECT rowid FROM scheduler_action_runs
WHERE service_id = ? AND action_key = ?
ORDER BY started_at DESC, rowid DESC LIMIT ?
)
)
""",
(service_id, action_key, cutoff, service_id, action_key, MAX_RUNS_PER_ACTION),
)
conn.commit()
return _safe_int(cursor.rowcount)
__all__ = [
"MAX_RUNS_PER_ACTION",
"RUN_RETENTION_SECONDS",
"SCHEDULER_CONCERN",
"SchedulerRunStore",
]
@@ -16,12 +16,33 @@ from __future__ import annotations
import logging
import os
import re
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _quote_identifier(value: str) -> str:
if not _IDENTIFIER_RE.fullmatch(value):
raise ValueError(f"Unsafe SQLite identifier: {value!r}")
return f'"{value}"'
def _cascade_delete(conn: sqlite3.Connection, table: str, column: str, service_id: str) -> None:
table_sql = _quote_identifier(table)
column_sql = _quote_identifier(column)
# Identifiers are strictly allowlisted; the value remains parameterized.
# nosemgrep: python.lang.security.audit.formatted-sql-query.formatted-sql-query
# nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query
conn.execute(
f"DELETE FROM {table_sql} WHERE {column_sql} = ?",
(service_id,),
)
@dataclass(frozen=True)
class StorageConcern:
@@ -96,7 +117,7 @@ class ServiceDataHarness:
conn.execute(stmt)
except sqlite3.OperationalError as exc:
lowered = str(exc).lower()
if "duplicate column name" in lowered or "no such table" in lowered:
if any(marker in lowered for marker in ("duplicate column name", "no such table")):
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
else:
raise
@@ -118,9 +139,7 @@ class ServiceDataHarness:
continue
with sqlite3.connect(path, timeout=30) as conn:
for table in concern.tables:
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if col in cols:
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
_cascade_delete(conn, table, col, service_id)
# ---------------------------------------------------------------------------
@@ -148,6 +167,10 @@ def get_service_data_harness() -> ServiceDataHarness:
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
_HARNESS.register(MEDIA_INDEX_CONCERN)
from .scheduler_store import SCHEDULER_CONCERN # type: ignore[reportMissingImports]
_HARNESS.register(SCHEDULER_CONCERN)
_HARNESS.run_migrations()
return _HARNESS