fix: include all active torrent states

This commit is contained in:
Developer
2026-07-14 17:20:27 +00:00
parent 03aece02b8
commit 230b4b8533
6 changed files with 46 additions and 19 deletions
@@ -117,7 +117,7 @@ DEFINITION = ServiceDefinition(
widget_kind( widget_kind(
kind="active", kind="active",
name="Active torrents", name="Active torrents",
description="Torrents currently downloading or uploading.", description="All active download/upload work, including queued and stalled transfers.",
model_cls=QbittorrentWidgetConfig, model_cls=QbittorrentWidgetConfig,
default_config={}, default_config={},
refresh_interval_ms=15_000, refresh_interval_ms=15_000,
@@ -411,9 +411,10 @@ def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> Qbittorren
_QBITTORRENT_DOWNLOAD_STATES = frozenset( _QBITTORRENT_DOWNLOAD_STATES = frozenset(
{"downloading", "forceddl", "stalleddl", "metadl", "allocating"} {"downloading", "forceddl", "stalleddl", "queueddl", "metadl", "forcedmetadl", "allocating", "checkingdl"}
) )
_QBITTORRENT_UPLOAD_STATES = frozenset({"uploading", "forcedup", "stalledup"}) _QBITTORRENT_UPLOAD_STATES = frozenset({"uploading", "forcedup", "stalledup", "queuedup", "checkingup"})
_QBITTORRENT_OTHER_ACTIVE_STATES = frozenset({"checkingresumedata", "moving"})
def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None: def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None:
@@ -430,6 +431,11 @@ def _qbit_torrent_direction(torrent: dict[str, Any]) -> str | None:
return None return None
def _qbit_torrent_is_active(torrent: dict[str, Any]) -> bool:
state = str(torrent.get("state") or "").lower()
return bool(_qbit_torrent_direction(torrent)) or state in _QBITTORRENT_OTHER_ACTIVE_STATES
class QbittorrentWidgetSource: class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets.""" """Fetch qBittorrent data for totals, active, and speed widgets."""
@@ -492,9 +498,9 @@ class QbittorrentWidgetSource:
if widget_kind == "active": if widget_kind == "active":
active = [] active = []
for torrent in torrents.values(): for torrent in torrents.values():
direction = _qbit_torrent_direction(torrent) if not _qbit_torrent_is_active(torrent):
if not direction:
continue continue
direction = _qbit_torrent_direction(torrent)
active.append( active.append(
{ {
"name": torrent.get("name"), "name": torrent.get("name"),
+5 -5
View File
@@ -1128,12 +1128,12 @@ async def test_qbittorrent_totals_counts_all_torrents():
assert result["by_state"]["uploading"] == 1 assert result["by_state"]["uploading"] == 1
assert result["by_state"]["queuedDL"] == 1 assert result["by_state"]["queuedDL"] == 1
assert result["by_state"]["pausedDL"] == 1 assert result["by_state"]["pausedDL"] == 1
assert result["by_direction"] == {"downloading": 2, "uploading": 2} assert result["by_direction"] == {"downloading": 3, "uploading": 2}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only(): async def test_qbittorrent_active_filters_dl_ul_only():
"""Active kind returns only downloading/uploading torrents (Q3).""" """Active kind returns all active download/upload states, including queued work."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource() adapter = QbittorrentWidgetSource()
@@ -1149,14 +1149,14 @@ async def test_qbittorrent_active_filters_dl_ul_only():
result = await adapter.fetch(service, "active", {}) result = await adapter.fetch(service, "active", {})
active = result["torrents"] active = result["torrents"]
assert len(active) == 4 assert len(active) == 5
names = [t["name"] for t in active] names = [t["name"] for t in active]
assert "Movie.mkv" in names assert "Movie.mkv" in names
assert "Show.mkv" in names assert "Show.mkv" in names
assert "Forced download" in names assert "Forced download" in names
assert "Stalled upload" in names assert "Stalled upload" in names
# Queued and paused are excluded assert "Queued" in names
assert "Queued" not in names # Paused torrents remain excluded, but queued transfer work is visible.
assert "Paused" not in names assert "Paused" not in names
+2 -1
View File
@@ -210,7 +210,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
{ {
kind: "active", kind: "active",
name: "Active torrents", name: "Active torrents",
description: "Torrents currently downloading or uploading.", description:
"All active download/upload work, including queued and stalled transfers.",
refreshIntervalMs: 15_000, refreshIntervalMs: 15_000,
defaultConfig: {}, defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] }, configSchema: { type: "object", properties: {}, required: [] },
@@ -40,19 +40,30 @@ function formatProgress(progress: number | null): string {
return `${Math.round(progress * 100)}% complete`; return `${Math.round(progress * 100)}% complete`;
} }
function formatState(state: string | null, direction?: ActiveTorrent["direction"]): string { function formatState(
state: string | null,
direction?: ActiveTorrent["direction"],
): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
downloading: "Downloading", downloading: "Downloading",
forcedDL: "Downloading", forcedDL: "Downloading",
stalledDL: "Download stalled", stalledDL: "Download stalled",
queuedDL: "Queued download",
metaDL: "Downloading metadata", metaDL: "Downloading metadata",
forcedMetaDL: "Downloading metadata",
checkingDL: "Checking download",
allocating: "Allocating", allocating: "Allocating",
uploading: "Uploading", uploading: "Uploading",
forcedUP: "Uploading", forcedUP: "Uploading",
stalledUP: "Upload stalled", stalledUP: "Upload stalled",
queuedUP: "Queued upload",
checkingUP: "Checking upload",
checkingResumeData: "Checking resume data",
moving: "Moving",
}; };
if (state && labels[state]) return labels[state]; if (state && labels[state]) return labels[state];
if (direction) return direction === "downloading" ? "Downloading" : "Uploading"; if (direction)
return direction === "downloading" ? "Downloading" : "Uploading";
return state || "Unknown state"; return state || "Unknown state";
} }
@@ -91,7 +102,8 @@ export function QbittorrentActiveTorrentsWidget({
{torrent.name ?? "Unknown torrent"} {torrent.name ?? "Unknown torrent"}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatSize(torrent.size)} · {formatProgress(torrent.progress)} {formatSize(torrent.size)} ·{" "}
{formatProgress(torrent.progress)}
</p> </p>
</div> </div>
<Badge <Badge
@@ -103,7 +115,8 @@ export function QbittorrentActiveTorrentsWidget({
</Badge> </Badge>
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{formatSpeed(torrent.dl_speed)} · {formatSpeed(torrent.up_speed)} {formatSpeed(torrent.dl_speed)} · {" "}
{formatSpeed(torrent.up_speed)}
</div> </div>
</li> </li>
); );
@@ -50,7 +50,9 @@ const UPLOAD_STATES = new Set(["uploading", "forcedUP", "stalledUP"]);
function stateLabel(state: string): string { function stateLabel(state: string): string {
return ( return (
STATE_LABELS[state] ?? STATE_LABELS[state] ??
state.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase()) state
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase())
); );
} }
@@ -76,7 +78,8 @@ export function QbittorrentTotalsWidget({
payload?.by_direction?.downloading ?? payload?.by_direction?.downloading ??
fallbackDirectionCount(byState, DOWNLOAD_STATES); fallbackDirectionCount(byState, DOWNLOAD_STATES);
const uploading = const uploading =
payload?.by_direction?.uploading ?? fallbackDirectionCount(byState, UPLOAD_STATES); payload?.by_direction?.uploading ??
fallbackDirectionCount(byState, UPLOAD_STATES);
const stateEntries = Object.entries(byState).sort( const stateEntries = Object.entries(byState).sort(
([stateA, countA], [stateB, countB]) => ([stateA, countA], [stateB, countB]) =>
countB - countA || stateLabel(stateA).localeCompare(stateLabel(stateB)), countB - countA || stateLabel(stateA).localeCompare(stateLabel(stateB)),
@@ -95,7 +98,9 @@ export function QbittorrentTotalsWidget({
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<div className="text-2xl font-semibold">{payload.total ?? 0}</div> <div className="text-2xl font-semibold">{payload.total ?? 0}</div>
<div className="text-xs text-muted-foreground">Total torrents</div> <div className="text-xs text-muted-foreground">
Total torrents
</div>
</div> </div>
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<div className="text-2xl font-semibold">{downloading}</div> <div className="text-2xl font-semibold">{downloading}</div>
@@ -126,7 +131,9 @@ export function QbittorrentTotalsWidget({
)} )}
</div> </div>
) : ( ) : (
<div className="text-xs text-muted-foreground">No torrent data available.</div> <div className="text-xs text-muted-foreground">
No torrent data available.
</div>
)} )}
</SectionCard> </SectionCard>
); );