Compare commits

..

2 Commits

Author SHA1 Message Date
Developer e8d3ab87a9 fix(qbittorrent): preserve torrent metadata in incremental updates 2026-07-21 12:26:43 +00:00
Developer 1bf8a34a97 fix(charting): remove duplicate range selector 2026-07-15 19:15:06 +00:00
3 changed files with 26 additions and 6 deletions
@@ -220,7 +220,12 @@ class QbittorrentClient:
if fields is None: if fields is None:
snap["torrents"].pop(hash_, None) snap["torrents"].pop(hash_, None)
else: else:
snap["torrents"][hash_] = fields previous = snap["torrents"].get(hash_)
snap["torrents"][hash_] = (
{**previous, **fields}
if isinstance(previous, dict) and isinstance(fields, dict)
else fields
)
for hash_ in update.get("torrents_removed") or []: for hash_ in update.get("torrents_removed") or []:
snap["torrents"].pop(hash_, None) snap["torrents"].pop(hash_, None)
categories = update.get("categories") categories = update.get("categories")
+17 -5
View File
@@ -125,13 +125,21 @@ class QbittorrentClientTests(unittest.TestCase):
"rid": 10, "rid": 10,
"full_update": True, "full_update": True,
"server_state": {"dl_info_speed": 100}, "server_state": {"dl_info_speed": 100},
"torrents": {"a": {"name": "A", "state": "downloading"}}, "torrents": {
"a": {
"name": "A",
"state": "downloading",
"size": 1_024,
"progress": 0.5,
"dlspeed": 100,
}
},
} }
partial = { partial = {
"rid": 11, "rid": 11,
"full_update": False, "full_update": False,
"server_state": {"dl_info_speed": 200}, "server_state": {"dl_info_speed": 200},
"torrents": {"a": {"name": "A", "state": "pausedDL"}}, "torrents": {"a": {"dlspeed": 200}},
} }
self.session.get.side_effect = [self._get_response(full), self._get_response(partial)] self.session.get.side_effect = [self._get_response(full), self._get_response(partial)]
@@ -143,7 +151,11 @@ class QbittorrentClientTests(unittest.TestCase):
r2 = self.client.maindata() r2 = self.client.maindata()
self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10) self.assertEqual(self.session.get.call_args_list[1].kwargs["params"].get("rid"), 10)
self.assertEqual(r2["server_state"]["dl_info_speed"], 200) # merged self.assertEqual(r2["server_state"]["dl_info_speed"], 200) # merged
self.assertEqual(r2["torrents"]["a"]["state"], "pausedDL") # merged self.assertEqual(r2["torrents"]["a"]["dlspeed"], 200)
self.assertEqual(r2["torrents"]["a"]["name"], "A")
self.assertEqual(r2["torrents"]["a"]["state"], "downloading")
self.assertEqual(r2["torrents"]["a"]["size"], 1_024)
self.assertEqual(r2["torrents"]["a"]["progress"], 0.5)
def test_maindata_caches_concurrent_calls_within_ttl(self) -> None: def test_maindata_caches_concurrent_calls_within_ttl(self) -> None:
"""Two calls within the TTL collapse to a single HTTP fetch.""" """Two calls within the TTL collapse to a single HTTP fetch."""
@@ -227,8 +239,8 @@ class QbittorrentClientTests(unittest.TestCase):
self.session.post.return_value = self._login_response() self.session.post.return_value = self._login_response()
self.client._login() self.client._login()
call_kwargs = self.session.post.call_args.kwargs call_kwargs = self.session.post.call_args.kwargs
assert call_kwargs["timeout"] == (5.0, 5.0) self.assertEqual(call_kwargs["timeout"], (5.0, 5.0))
assert not isinstance(call_kwargs["timeout"], int) self.assertNotIsInstance(call_kwargs["timeout"], int)
def test_login_fails_message_names_bad_credentials(self) -> None: def test_login_fails_message_names_bad_credentials(self) -> None:
"""'Fails.' body yields a clear 'invalid username or password' error.""" """'Fails.' body yields a clear 'invalid username or password' error."""
+3
View File
@@ -321,6 +321,9 @@ These do not reference a service.
- The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart. - The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart.
- Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff. - Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff.
- The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples. - The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
- The qBittorrent client must merge incremental torrent patches with the prior
snapshot so active-transfer rows retain their name, size, progress, and state
when only throughput changes.
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, the shared selectable chart windows, an **All values** option that fetches every retained speed sample, and paginated scheduled-action history. - The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, the shared selectable chart windows, an **All values** option that fetches every retained speed sample, and paginated scheduled-action history.
- Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials. - Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials.
- Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior. - Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior.