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
@@ -41,6 +41,13 @@ from media_library_viewer_api.widgets.stats_provider import get_stats_provider
logger = logging.getLogger(__name__)
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
@dataclass
class ServiceRecord:
"""Runtime view of a service instance with decrypted secrets."""
@@ -179,7 +186,7 @@ class MetricSource:
try:
return await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
except asyncio.TimeoutError:
except asyncio.TimeoutError as _timeout_error:
return {"error": "Grafana query timed out"}
except requests.RequestException as exc:
logger.exception("grafana gateway query failed")
@@ -299,7 +306,7 @@ class AlertmanagerWidgetSource:
payload = response.json()
alerts = payload.get("data", []) if isinstance(payload, dict) else []
return summarize_alerts(alerts, severity_filter=severity_filter)
except asyncio.TimeoutError:
except asyncio.TimeoutError as _timeout_error:
return {"error": "Widget data fetch timed out"}
except requests.RequestException as exc:
logger.exception("alertmanager adapter failed")
@@ -334,7 +341,7 @@ class JellyfinWidgetSource:
]
rows = _map_sessions_to_activity_rows(sessions)
return {"sessions": rows}
except asyncio.TimeoutError:
except asyncio.TimeoutError as _timeout_error:
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("jellyfin adapter failed")
@@ -365,7 +372,7 @@ class SshTaskWidgetSource:
timeout=timeout,
)
return {"exit_status": result.exit_status, "stdout": result.stdout, "stderr": result.stderr}
except asyncio.TimeoutError:
except asyncio.TimeoutError as _timeout_error:
_record_timeout(service, config, timeout)
return {"error": "Widget data fetch timed out"}
except Exception as exc:
@@ -410,6 +417,26 @@ class QbittorrentWidgetSource:
try:
if service is None:
return {"error": "qBittorrent widget is missing its service"}
if widget_kind == "speed":
window_seconds = _safe_int(
config.get("window_seconds") or service.config.get("sample_retention_seconds") or 1_800
)
window_seconds = max(60, min(window_seconds, 86_400))
since_ts = _safe_int(time.time()) - window_seconds
samples = QbittorrentSampleStore().window(service.id, since_ts=since_ts)
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}
base_url = str(service.config.get("base_url") or "")
username = str(service.secrets.get("username") or "")
password = str(service.secrets.get("password") or "")
@@ -425,7 +452,6 @@ class QbittorrentWidgetSource:
# gateway timeouts. The client re-logins itself on a 403.
client = _qbittorrent_client((service.id, 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":
@@ -450,21 +476,8 @@ class QbittorrentWidgetSource:
]
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:
except asyncio.TimeoutError as _timeout_error:
return {"error": "qBittorrent data fetch timed out"}
except Exception as exc:
logger.exception("qbittorrent adapter failed")
@@ -512,12 +525,10 @@ class StatsWidgetSource:
provider = get_stats_provider(service.service_type)
if provider is None:
return {"error": f"No stats provider for service type '{service.service_type}'"}
timeout = int(service.config.get("timeout_seconds") or 30)
timeout = _safe_int(service.config.get("timeout_seconds") or 30, 30)
try:
result = await asyncio.wait_for(
asyncio.to_thread(provider.fetch_stats, service), timeout=timeout
)
except asyncio.TimeoutError:
result = await asyncio.wait_for(asyncio.to_thread(provider.fetch_stats, service), timeout=timeout)
except asyncio.TimeoutError as _timeout_error:
return {"error": "Stats fetch timed out"}
except Exception as exc:
logger.exception("stats provider failed service=%s", service.id)