Skip to content

fix(qbittorrent): poll only what changed via /sync/maindata - #186

Open
v3DJG6GL wants to merge 17 commits into
retransmit:developmentfrom
v3DJG6GL:feat/qbit-delta-sync
Open

v3DJG6GL wants to merge 17 commits into
retransmit:developmentfrom
v3DJG6GL:feat/qbit-delta-sync

Conversation

@v3DJG6GL

@v3DJG6GL v3DJG6GL commented Oct 9, 2026

Copy link
Copy Markdown

Issue

Every 3 seconds the torrent list downloaded every torrent from /torrents/info and parsed all of them again. With thousands of torrents that's slow and uses a lot of data. On top of that, pollEvery ticks on a fixed timer, so when a fetch took longer than the interval, the next tick restarted it and the spinner never went away.

Changes

  • The list now uses /sync/maindata, the same endpoint qBittorrent's web UI uses. The first call returns everything. After that, it returns only changed fields, new torrents and removed hashes.
  • QbitSyncStore keeps the merged list per instance. It isn't autoDispose, so reopening the screen continues from the last rid instead of fetching the full list again.
  • Syncs run one at a time, so an older reply can't land after a newer one and overwrite it.
  • If a reply fails to merge, rid goes back to 0 and the next poll gets the full list.
  • Every qBittorrent poll (list, transfer info, properties, files, trackers, peers) now uses ref.polled instead of pollEvery.
  • The list interval is set per instance with the existing "Polling Interval" field on the instance form, which now shows for qBittorrent. Detail screens stay at 10s.
  • Behavior change: the default list interval is now 5s (the instance default), up from a fixed 3s. Anything under 1s, e.g. from an imported profile, is raised to 1s.

Size

added removed
code 94 36
comments 42 8
tests 302 0
docs 3 2

Testing

  • flutter analyze is clean for service_qbittorrent and app. All 73 tests in service_qbittorrent pass, as well as other qBittorrent and polling tests.
  • New tests: full update, partial delta (every model field survives a merge), added and removed torrents, a full update dropping stale torrents, failed merge resets rid, overlapping syncs wait and use the newer rid, a failed sync doesn't block the next one, reopening resumes the delta, interval comes from the instance with a 1s floor, and the form shows the field for qBittorrent.

LLM Disclosure

Developed with assistance from Claude Code (Opus 5.5). The LLM helped me identify the related files, code parts and proposed possible code modifications and fixes.
I've reviewed, cleaned, refactored and hardened the code the best I could, but my Dart capabilities are not that good.

v3DJG6GL and others added 17 commits October 9, 2026 11:48
The torrent list re-downloaded and re-parsed every torrent each 3s tick,
and pollEvery's blind timer restarted fetches slower than the interval
forever - an endless spinner on large instances (#10k torrents). Polls now
go through ref.polled (next tick armed after the fetch settles) and fetch
only the delta since the previous response; a per-instance store keeps the
merged list and survives screen exits, so re-entering paints instantly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reuses Instance.pollingIntervalSeconds (already editable for Glances) and
shows the Polling Interval field on the qBittorrent instance form. List and
transfer-stat polls follow it; detail views keep their fixed 10s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A refresh fired while a /sync/maindata poll was in flight (pull-to-refresh,
or the invalidate after a torrent action) went out with the same rid. Two
replies to one rid can land out of order, so the older one overwrote newer
fields and wound the rid back until a full update healed it. QbitSyncStore
now chains its fetches, so each one asks with the rid of the reply before
it; a failed fetch does not block the next.

The store's doc no longer claims re-entering the screen paints instantly:
the autoDispose list provider still loads once, only its first fetch is a
small delta now.

Adds tests for the merge (full update, field patch, removal, restart) and
for the chaining.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sync store is never disposed, and it kept every torrent's full raw
row from qBittorrent (~60 keys incl. paths and info hashes) next to the
parsed model - several KB per torrent for the rest of the session, tens
of MB on a large seedbox. Merge each patch over the stored model's own
JSON instead: toJson/fromJson round-trip every modelled field, so the
result is identical for omitted fields, explicit nulls and extra keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store moved rid on before merging a maindata reply, so a reply that
failed to parse part way left a half-merged list marked as caught up;
the server never resends what was skipped. The old per-tick full fetch
healed itself on the next poll. Advance rid only once the whole reply
merged, and reset it to 0 on failure so the next sync gets a full update.
Not reachable against a well-behaved server (verified against qBit's
per-session sync state); this restores the old self-healing.

Also cover a torrent added between polls, a reply without a rid, and
removing a torrent the store does not hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same interval drives the transfer-info poll, so a tick is not only a
maindata delta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list poll interval is now per instance, and the form field is the
only way to change it. Pin it next to the Gluetun case so trimming the
form's kind list cannot silently strand qBittorrent on the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The torrent model is now mostly rebuilt from maindata patches merged
over its own JSON, so say that its keys must stay the server's. The add
doc pointed at a full fetch the app no longer uses. The not-autoDispose
note belongs on the provider, where that choice is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elta

The store's provider outliving the autoDispose list is what keeps a
re-entered screen from downloading every torrent again, and every other
test overrides the list provider wholesale. Drive the real wiring
through a container: open, leave, re-open, and expect rid 0 then 1.
Fails as [0, 0] if the store provider is made autoDispose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list interval now comes from the instance. The form refuses less
than 1s, but an imported profile is saved unchecked, and 0 (or less)
would re-run the list and speed requests back to back, with an error
backoff of 0 x 5 as well. Floor it at 1s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge test compared only name and download speed, which keep their
server key as is. Comparing the whole model also covers the fields stored
under a renamed snake_case key, so a toJson that stopped writing the
server's keys would now fail the test (mutation-checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
STATUS.md still gave the old fixed 3s cadence; the list now polls only
what changed, on the instance's own interval (default 5s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test file's header described only Gluetun, though it now also checks
that the qBittorrent form offers the polling interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t sends them

The getMainData doc read as if a full_update reply still held only the
changes; it holds the full list, which is why the store clears on it. The
rid reset doc claimed the server would never resend a skipped reply, but
qBittorrent resends what follows the last rid the client sent back; the
reset is there because that resend would land on a half-merged list and
likely fail on the same entry again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… merge test

The fixture row left seeds, peers, priority, ETA, category, tags, tracker,
ratio and the session counters at the model's own defaults, so a merge that
dropped any of them would rebuild the default and the whole-model compare
still passed. Mutation-checked: removing num_seeds from the merge base now
fails the test; it passed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…interval

Only the 0 → 1s floor was pinned, so a qbitListPollInterval that ignored
pollingIntervalSeconds altogether still passed every test. Mutation-checked:
returning a constant 1s fails the new test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test only checks the interval helper, not that the list provider
polls on it, so its name no longer claims the latter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Bhavyashah94 Bhavyashah94 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this PR against a live qBittorrent instance (v5.2.4) with ~75 torrents, as well as running on an Android emulator (Pixel 7 / API 35). Leaving a review comment with test findings for other testers and maintainers.

Summary of Testing

  1. Delta Sync & Store Logic (QbitSyncStore):

    • Verified initial sync behavior (rid: 0 receives full_update: true and parses all torrents).
    • Subsequent polling ticks query /sync/maindata?rid=$rid and only receive changed fields (e.g. download/upload speeds, progress, eta), cutting down bandwidth significantly compared to polling /torrents/info.
    • Verified that partial field patches correctly preserve unchanged model properties (added_on, category, save_path, magnet_uri, etc.) across JSON merge roundtrips.
    • Verified sequential request queuing via _tail Future chaining to prevent overlapping calls (e.g. manual refresh vs timer tick) from racing or winding back rid.
    • Verified that if a malformed patch fails to apply, rid resets to 0 so the next poll recovers with a clean full fetch.
  2. Polling Interval Configuration:

    • Verified that the InstanceFormScreen now renders the Polling section and Polling Interval (seconds) field when configuring a qBittorrent instance (defaulting to 5s).
    • Verified the interval logic enforces a 1s minimum (max(1, instance.pollingIntervalSeconds)).
  3. Android Client UI (Emulator):

    • Dashboard: "Active downloads" card updates live download speeds smoothly without spinner thrashing.
    • Activity Tab: Real-time speed updates and download progress render reactively.
    • Torrent List: Torrents render with stats, status badges, and search without full-list redraw flicker on every tick.
    • Torrent Details: Detail sheets (Overview, Files, Trackers, Peers) open reliably and remain consistent with the synced state.
  4. Automated Tests:

    • All 73 tests in services/service_qbittorrent pass (including the new qbit_sync_store_test.dart suite).
    • Form widget test (gluetun_form_polling_test.dart) passes.
    • dart analyze / flutter analyze reports 0 issues.

Everything looks solid and operates cleanly on live data. Leaving review open without approval so other testers can also test and verify on their setups.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants