diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index 0836462..f0aed93 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -410,6 +410,26 @@ def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> Qbittorren return QbittorrentClient(base_url, username, password, timeout=timeout) +_QBITTORRENT_DOWNLOAD_STATES = frozenset( + {"downloading", "forceddl", "stalleddl", "metadl", "allocating"} +) +_QBITTORRENT_UPLOAD_STATES = frozenset({"uploading", "forcedup", "stalledup"}) + + +def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None: + """Return the transfer direction for active qBittorrent states or speeds.""" + state = str(torrent.get("state") or "").lower() + if state in _QBITTORRENT_DOWNLOAD_STATES: + return "downloading" + if state in _QBITTORRENT_UPLOAD_STATES: + return "uploading" + if _safe_int(torrent.get("dlspeed")) > 0: + return "downloading" + if _safe_int(torrent.get("upspeed")) > 0: + return "uploading" + return None + + class QbittorrentWidgetSource: """Fetch qBittorrent data for totals, active, and speed widgets.""" @@ -456,24 +476,36 @@ class QbittorrentWidgetSource: if widget_kind == "totals": by_state: dict[str, int] = {} - for t in torrents.values(): - state = str(t.get("state", "unknown")) + by_direction = {"downloading": 0, "uploading": 0} + for torrent in torrents.values(): + state = str(torrent.get("state") or "unknown") by_state[state] = by_state.get(state, 0) + 1 - return {"total": len(torrents), "by_state": by_state} + direction = _qbit_torrent_direction(torrent) + if direction: + by_direction[direction] += 1 + return { + "total": len(torrents), + "by_state": by_state, + "by_direction": by_direction, + } 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"} - ] + active = [] + for torrent in torrents.values(): + direction = _qbit_torrent_direction(torrent) + if not direction: + continue + active.append( + { + "name": torrent.get("name"), + "state": torrent.get("state"), + "direction": direction, + "size": torrent.get("size"), + "progress": torrent.get("progress"), + "dl_speed": torrent.get("dlspeed"), + "up_speed": torrent.get("upspeed"), + } + ) return {"torrents": active} return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"} diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 8812ad6..48faa9d 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -1086,6 +1086,22 @@ def _fake_qbit_maindata(): "dlspeed": 0, "upspeed": 0, }, + "h5": { + "name": "Forced download", + "state": "forcedDL", + "size": 5000, + "progress": 0.4, + "dlspeed": 0, + "upspeed": 0, + }, + "h6": { + "name": "Stalled upload", + "state": "stalledUP", + "size": 6000, + "progress": 1.0, + "dlspeed": 0, + "upspeed": 0, + }, }, } @@ -1107,11 +1123,12 @@ async def test_qbittorrent_totals_counts_all_torrents(): mock_client.return_value.maindata.return_value = _fake_qbit_maindata() result = await adapter.fetch(service, "totals", {}) - assert result["total"] == 4 + assert result["total"] == 6 assert result["by_state"]["downloading"] == 1 assert result["by_state"]["uploading"] == 1 assert result["by_state"]["queuedDL"] == 1 assert result["by_state"]["pausedDL"] == 1 + assert result["by_direction"] == {"downloading": 2, "uploading": 2} @pytest.mark.asyncio @@ -1132,10 +1149,12 @@ async def test_qbittorrent_active_filters_dl_ul_only(): result = await adapter.fetch(service, "active", {}) active = result["torrents"] - assert len(active) == 2 + assert len(active) == 4 names = [t["name"] for t in active] assert "Movie.mkv" in names assert "Show.mkv" in names + assert "Forced download" in names + assert "Stalled upload" in names # Queued and paused are excluded assert "Queued" not in names assert "Paused" not in names diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index e64df07..8953ff8 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -286,8 +286,9 @@ values missing an `http://` or `https://` schema with a clear validation error registry; every run is recorded in `service_task_runs` as history. Multiple instances per service type are supported. Services are managed from -**Settings → Services**, which provides a **List** subtab for creating and editing -instances and a **Dashboards** subtab for named dashboard management. Each +**Settings → Services**, which provides a list view for creating and editing +instances. Named dashboards are managed in their own **Settings → Dashboards** +tab. Each instance retains its operational detail page at `/services/:serviceType/:serviceId`; legacy `/services` navigation redirects to Settings. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab9d6d2..aa78a8f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -467,7 +467,10 @@ function AppInner() { } /> } /> } /> - } /> + } + /> } @@ -495,7 +498,10 @@ function AppInner() { } /> } /> } /> - } /> + } + /> } diff --git a/frontend/src/pages/ServicesPage.tsx b/frontend/src/pages/ServicesPage.tsx index a5227d3..029b16e 100644 --- a/frontend/src/pages/ServicesPage.tsx +++ b/frontend/src/pages/ServicesPage.tsx @@ -100,7 +100,10 @@ function ServiceConfigFields({ const properties = ( type.config_schema as { - properties?: Record; + properties?: Record< + string, + { type?: string; description?: string; format?: string } + >; } ).properties ?? {}; // Multi-line resizable textarea for fields that hold complex values (opt-in @@ -110,40 +113,41 @@ function ServiceConfigFields({
{Object.entries(properties).map(([key, schema]) => { const isNumber = schema.type === "integer" || schema.type === "number"; - const isTextarea = schema.format === "textarea" || TEXTAREA_KEYS.has(key); + const isTextarea = + schema.format === "textarea" || TEXTAREA_KEYS.has(key); return ( - - {isTextarea ? ( -