Full qBitrr → Torrentarr parity path report¶
End-to-end review of every user-facing feature and logical path, compared to upstream qBitrr.
| Field | Value |
|---|---|
| qBitrr baseline | v5.14.4-1 / current master (EXPECTED_CONFIG_VERSION = 5.14.4) |
| Torrentarr | product 6.14.4-1, schema 6.14.4 (+1 major) |
| Contracts | same config.toml format; same logical SQLite schema; DB file is torrentarr.db |
| Row-level file map | full-parity-matrix.md |
| Contributor pin / tests | contributor-reference.md |
| User overview | overview.md |
Legend:
- Match — same user-facing outcome as qBitrr
- Arch — differs because of .NET Host / in-process workers; outcome is equivalent by design
- Drift — Torrentarr behavior or docs disagree with qBitrr
- Gap — code missing, unused, or not wired on the production Host path
This report tracks user-facing paths vs qBitrr. Closed gaps from the follow-up pass are marked Match; remaining differences are Arch (in-process tasks vs pathos OS forks, WAL vs db_lock, weekly-build matching upstream).
Matrix vs current upstream layout¶
full-parity-matrix.md maps the split qBitrr/arss/ package plus:
config_reload_policy.py→ConfigReloaderprocess_lifecycle.py→ArrWorkerManager/ProcessStateManagerquality_profile_helpers.py→QualityProfileSwitcherServiceradarr_availability.py→MinimumAvailabilityCheckqbit_seeding_config.py→SeedingLimitMergearr_client.py→ApiClients/Arr/*.cs
scripts/repair_database_targeted.py is intentional-divergence (Host --repair-database is the operator path), matching contributor-reference.md.
1. Process topology¶
flowchart TB
subgraph qBitrr [qBitrr Python]
MainPy[main.py]
SearchProc[search loop process]
TorrentProc[torrent loop process]
WebPy[webui]
MainPy --> SearchProc
MainPy --> TorrentProc
MainPy --> WebPy
end
subgraph torrentarr [Torrentarr NET]
Host[Torrentarr.Host]
ArrWM[ArrWorkerManager in-process]
QBitWM[QBitCategoryWorkerManager]
Policy[ProcessOrchestratorService]
AutoUp[AutoUpdateBackgroundService]
Host --> ArrWM
Host --> QBitWM
Host --> Policy
Host --> AutoUp
end | Area | Status | Notes |
|---|---|---|
OS forks (pathos) vs Host BackgroundService | Arch | Canonical loop: ArrWorkerManager.cs. Torrentarr.Workers exists but Host does not spawn it. |
Processes UI rows {name}-search / {name}-torrent | Match | Same surface as qBitrr even though both are in-process tasks. |
| Docs saying “per-Arr worker processes” | Match | overview.md and features/process-management.md describe Host in-process worker tasks. |
db_lock.py | Arch | WAL + SaveChangesWithRetryAsync + DatabaseRestartWatchdogService. |
| Pathos dedicated-qBit-client gate / placeholder pause-resume queues (5.12.6 / 5.12.8) | Arch | Per-torrent QBitInstanceName routing; no default client. |
Host orchestrator (Program.cs ProcessOrchestratorService), each cycle:
- Connect all qBit instances.
- Special categories: delete
FailedCategory, recheckRecheckCategory(age-gated bySettings.IgnoreTorrentsYoungerThan). - If SortTorrents or free space is enabled:
ProcessTorrentPolicyAsync(trackertopPrio+ pause/resume). - Sleep
LoopSleepTimer.
2. Per-Arr worker loop (every feature gate)¶
Canonical path: ArrWorkerManager.RunWorkerCoreAsync.
Startup (once)¶
- Log search flags (
SearchMissing,DoUpgradeSearch,QualityUnmetSearch,CustomFormatUnmetSearch). - Mark search/torrent process states alive.
ProbeArrVersionAsync(5s, non-blocking).- If
UseTempForMissing && ForceResetTempProfiles→QualityProfileSwitcherService.ForceResetAllTempProfilesAsync. QBitCategoryEnsureService.EnsureCategoryOnAllInstancesAsync+SeedingService.EnsureAllTrackerTagsExistAsync.- If
FFprobeAutoUpdate,IMediaValidationService.UpdateFFprobeAsynconce.
Dual in-process loops¶
Two tasks per Arr instance (joined with Task.WhenAll; still one _workers entry):
| Loop | Each iteration | Sleep |
|---|---|---|
| Torrent | connectivity → ProcessTorrentsAsync if !SearchOnly → RSS / refresh timers | remainder of LoopSleepTimer |
| Search | connectivity → SyncAsync + MarkRequestsAsync (if SearchMissing) + counts → RunSearchAsync if ShouldRunSearch | remainder of LoopSleepTimer (search execution still throttled by SearchRequestsEvery) |
| Step | Gate | Torrentarr | qBitrr |
|---|---|---|---|
| Connectivity | PingURLS fail | sleep NoInternetSleepTimer, skip that loop’s cycle | same idea |
| Torrents | !SearchOnly | torrent task: TorrentProcessor.ProcessTorrentsAsync + import-path cleanup | separate torrent process |
| Sync | always (search task) | ArrSyncService.SyncAsync + MarkRequestsAsync if SearchMissing + UpdateCountsAsync | db_update in search/torrent loops |
| RSS / refresh | timers | torrent task | same commands |
| Search | SearchMissing and !ProcessingOnly and SearchRequestsEvery elapsed | search task: RunSearchAsync | separate search process, spawned when search_missing is true |
| Backoff | error | per-loop min(2×1.5^n, 30) minutes | process restart limits |
| Sleep | remainder of LoopSleepTimer | two tasks | two OS processes |
Arch: no RestartLoopException / pathos forks. Search is not blocked behind torrent processing. Sync stays on the search task at LoopSleepTimer so it is not deferred to SearchRequestsEvery. WAL + SaveChangesWithRetryAsync remain the lock equivalent.
Match: SearchMissing is the master switch for the search loop, upgrade/CF search, and Ombi/Overseerr request marking (ShouldRunSearch / MarkRequestsAsync / RunSearchAsync).
Restart limits (AutoRestartProcesses, MaxProcessRestarts, ProcessRestartWindow, ProcessRestartDelay) apply per Arr instance. Match with qBitrr process-restart policy, implemented as task restart rather than OS-process restart.
3. Search logical paths¶
flowchart TD
start[RunSearchAsync]
start --> temp{UseTempForMissing and timeout}
temp -->|yes| restore[RestoreTimedOutProfilesAsync]
temp -->|no| mode
restore --> mode{DoUpgradeSearch}
mode -->|true exclusive| upgrades[SearchQualityUpgradesAsync only]
mode -->|false| missing{SearchMissing}
missing -->|true| missSearch[SearchMissingMediaAsync]
missing -->|false| add
missSearch --> add{QualityUnmet or CustomFormatUnmet}
add -->|true| upgrades2[SearchQualityUpgradesAsync additive]
add -->|false| done[no missing or unmet search]
upgrades --> again
upgrades2 --> again
done --> again{SearchAgainOnSearchCompletion and previous tick LoopCompleted}
again -->|yes| reset[Reset Searched and Upgrade flags] Implementation: ArrWorkerManager.RunSearchAsync, ArrMediaService, SearchExecutor.
Missing-media candidates (GetSearchCandidatesAsync)¶
- Types: Radarr movies, Sonarr episodes, Lidarr albums, Readarr books.
- Filters:
ArrInstancedictionary key,MonitoredunlessUnmonitored,!HasFile,!Searched. - Skip specials if
!AlsoSearchSpecials(Sonarr season 0). PrioritizeTodaysReleases: air/release in −25h / −1h window.- Reason priority: Missing=1, CustomFormat=2, Quality=3, Upgrade=4, None/NotAvailable=99 (skipped).
- Availability: Radarr
MinimumAvailabilityCheck; episodes/albums/books date windows.
Upgrade candidates (GetUpgradeCandidatesAsync)¶
- Has file + monitored.
DoUpgradeSearch→!Upgrade(sync resetsUpgrade=false; search setsUpgrade=trueviaMarkAsSearchedAsync).- Else →
!Searched. - When
DoUpgradeSearch,GetUpgradePriorityalways returns Upgrade (searches all files). Otherwise only quality/CF unmet.
This matches qBitrr: should_mark_searched does not look at do_upgrade_search; upgrade loops use the Upgrade flag.
Executor¶
- Sort: Priority, today’s release, Year ASC or DESC (
SearchInReverse). - Cap:
SearchLimitvs active Arr commands (queued/started/runningplus search command names). - Delay:
SearchLoopDelayif >0, else 30s. Settings default is -1, so 30s unless a positive value is set. UseTempForMissing→ switch to temp profile before search.- Sonarr
SearchBySeries:"true"/"smart"(count > 1) /"false". - Commands: MoviesSearch, EpisodeSearch, SeriesSearch, AlbumSearch, ArtistSearch, BookSearch, AuthorSearch.
- After trigger:
Searched=trueandUpgrade=true, scoped toArrInstance.
Requests (Ombi / Overseerr)¶
MarkRequestsAsynconly ifSearchMissingandSearchOmbiRequests/SearchOverseerrRequests.- Radarr and Sonarr only (Lidarr/Readarr correctly skipped).
- Overseerr gates unreleased titles via TMDB (
OverseerrRequestFetcher, qBitrr 5.12.12).
Temporary quality profiles¶
- Apply to all four Arr types in
QualityProfileSwitcherService. - Restore only after a successful Arr PUT (Match with later Codex/qBitrr correctness).
SearchAgainOnSearchCompletion¶
SearchExecutor sets SearchResult.LoopCompleted when the candidate list is fully drained (empty set counts as drained; SearchLimit / cancel does not). The next search tick, if SearchAgainOnSearchCompletion && loopCompleted, resets both Searched and Upgrade for that instance. Match
Lidarr QualityMet¶
hasAllTracks = statistics.percentOfTracks == 100. Quality is unmet only if the profile has cutoff and upgradeAllowed and any track file quality.quality.id < cutoff. QualityMet = hasAllTracks && !qualityUnmet. API errors treat unmet as false. Match
4. Torrent state machine (every branch)¶
Source: TorrentProcessor.ProcessSingleTorrentAsync, annotated against qBitrr _process_single_torrent.
Pre-steps (every torrent)¶
- Tracker actions + seeding limits unless Host policy owns SortTorrents for that category.
ResolveLeaveAloneAsync: leave_alone / maxEta / removeTorrent; free-space-paused → leave alone;qBitrr-allowed_seedingtag.- Stalled check for MetaDL / StalledDL / Downloading: too young → ignore; within
StalledDelay→qBitrr-allowed_stalled; else delete/re-search ifReSearchStalled(age: added and last_activity). qBitrr-ignored→ strip seeding/free-space tags, skip.
Branches (first match wins)¶
- Custom-format unmet (
CustomFormatUnmetSearch) → delete if HnR allows. removeTorrent && !leaveAlone && AmountLeft==0→ delete (HnR).- Failed category → delete (no HnR).
- Recheck category → recheck (Host also does this globally).
- Missing files → delete from client, no blacklist.
- Ignored qBit states → skip.
- Stopped + leave_alone → resume.
- Stalled DL/MetaDL + not stalled_ignore → stalled processor.
- Downloading + not file-filtered → folder/name regex +
FileExtensionAllowlist;AutoDelete. - Timed ignore cache → resume if stopped, else skip.
- Queued upload → pause if
!leave_alone. - Paused download with remaining data → resume.
- Percentage threshold (
MaximumDeletablePercentage, default 0.99) → protect near-complete. - Already scanned/imported → finalize when Arr queue is gone (
IsImportedAsync, 5.12.7). - Error state → recheck.
- Complete + 60s grace → import (
Downloaded*Scan); leave_alone / ForcedUL → allow seeding instead. - Uploading + RemoveMode set → pause if
!leave_alone. - Slow download:
0 < maxEta < Etaand!DoNotRemoveSlow→ delete (HnR); last_activity stale. - Downloading: availability-based delete or continue file processing.
- Complete + leave_alone → resume seeding.
- Default unprocessed.
Import path¶
complete → 60s grace → ArrImportService.TriggerImportAsync → wait until the item leaves the Arr queue → Imported=true / qbitrr-imported. Multi-instance: client from torrent.QBitInstanceName only (no fallback). Match with 5.12.5–5.12.7.
FFprobe¶
After a successful Downloaded*Scan (TriggerImportAsync), if Torrent.AutoDelete and the content path exists, TorrentProcessor probes allowlisted files (ValidateDirectoryAsync). Zero valid media → Arr queue delete with blacklist + delete local files. Missing ffprobe binary and ebook/comic suffixes count as valid. Not FailedCategory. Host registers IMediaValidationService; FFprobeAutoUpdate runs once on worker start. Match (qBitrr folder_cleanup)
5. Seeding, HnR, trackers, free space¶
SeedingService¶
- Per-qBit-instance
[qBit.CategorySeeding]and[qBit.Trackers]; never cross-apply. Match - Merge
-1(unlimited) only if no source sets a positive limit (SeedingLimitMerge, qBitrr 5.14.2). Match HitAndRunMode:and/or/disabled. Progress belowHitAndRunMinimumDownloadPercent→ treat as safe; partial →HitAndRunPartialSeedRatio; dead-tracker message bypass (#412, not a bare"not found"). MatchRemoveMode/RemoveTorrent-1/½/¾ on uploading; HnR while downloading. Match- Tracker inject/remove, tags, super-seed,
RemoveDeadTrackers/RemoveTrackerWithMessage. Match - Queue sort priority for
SortTorrents. Match (live qBit still needed for full ordering proof)
Free space (Host, all qBit instances)¶
- Disabled if
FreeSpace == "-1". - Requires
AutoPauseResume, folder exists, qBit configured. - Gather torrents from all clients, sort globally by
AddedOn, oldest first; pause/resume withqBitrr-free_space_paused(or Tagless DB column). Match
qBit-only categories¶
QBitCategoryWorkerManager for ManagedCategories without Arr (qBitrr PlaceHolderArr / placeholder_arr.py). Match
6. Sync, catalog, database¶
ArrSyncService per Arr type: media upsert, queue sync, ArrErrorCodesToBlocklist scan, destructive-delete guards if the API returns empty against a large local set.
| Type | Identity / scores | Notes |
|---|---|---|
| Movies | TMDB key; CF from movie file; Upgrade=false on update | Match |
| Episodes | wipe + reinsert per series; Upgrade defaults false | Match |
| Albums / tracks / artists | artists keyed by ArrId | Match |
| Books / authors | authors by ArrId; CF from first book file per book (GetBookFilesByAuthorAsync); empty files while stats claim files → treat missing | Match |
DetermineSearched = qBitrr should_mark_searched (has file and no active quality/CF search). Does not look at DoUpgradeSearch. Match
Catalog identity: UI category (readarr-books) vs section key (Readarr-Books) via ArrCatalogIdentity.QueryKeys (case-sensitive keys). Match
Rollups: available = monitored AND has_file, 5s TTL. Match
Schema: qBitrr lowercase table names; Readarr tables on new and existing DBs (ManualSqliteMigrations). Match
7. Config, auth, WebUI, packaging¶
Config search order: TORRENTARR_CONFIG → ./.config/config.toml → ~/config/config.toml → ~/.config/qbitrr/config.toml → ~/.config/torrentarr/config.toml → ./config.toml. Env: TORRENTARR_* and QBITRR_*. Match
Arr sections: Radarr-* / Sonarr-* / Lidarr-* / Readarr-* with .Torrent, .EntrySearch, nested Ombi/Overseerr, SkipTLSVerify, SearchOnly, ProcessingOnly, MatchSubcategories, ImportMode, RSS/refresh timers. Match
WebUI: processes, logs, radarr, sonarr, lidarr, readarr, qbittorrent, config. Auth: token / local bcrypt / OIDC; first-run setup token. UrlBase + SPA reload. Empty-state loading; qBit groups collapsed. Dual /web/* and /api/*. OpenAPI drift vs qBitrr master in CI. Match
Updates: AutoUpdateChannel latest / stable / nightly (nightly is check-only; source builds never apply). Docker :latest / :stable (non-build) / :nightly (master) / v*. Weekly Dependabot squash + release_type=build matches qBitrr (Cursor Security finding is shared upstream behavior, not a Torrentarr-only hole).
CLI: --version, --license, --source, --gen-config, --repair-database, --backup-database. Match (table-scoped Python repair_database_targeted.py is not cloned; Host --repair-database is the operator path).
8. Feature × Arr coverage (actual code)¶
| Feature | Radarr | Sonarr | Lidarr | Readarr |
|---|---|---|---|---|
| Health / stalled / slow / failed / recheck | yes | yes | yes | yes |
Instant import (Downloaded*Scan) | yes | yes | yes | yes |
| Re-search / blocklist | yes | yes | yes | yes |
| Missing + upgrade + CF search | yes | yes | yes | yes |
| Temporary quality profiles | yes | yes | yes | yes |
| Overseerr / Ombi | yes | yes | no (correct) | no (correct) |
SearchBySeries / today’s window | — | yes | — | — |
Year sort (SearchInReverse) | yes | yes | yes | yes |
AlsoSearchSpecials | — | yes | — | — |
FFprobe after import (AutoDelete cleanup) | yes | yes | yes | yes |
| Catalog UI | movies | series | artists / albums | authors / books (no tracks) |
| Tagless free-space column | yes (global) | yes | yes | yes |
qBit-only ManagedCategories | via Host QBitCategoryWorkerManager (all instances) |
9. Honest gap list¶
Closed in the follow-up pass (now Match): ffprobe post-import AutoDelete cleanup, SearchMissing master switch, explicit loop_completed, Lidarr QualityMet, removal of unused IsQualityUpgradeAsync, matrix/arss/ + extra modules, feature/process docs.
Remaining:
- Arch — process model: in-process
Tasks vs qBitrr pathos OS forks. User-facing search/torrent rows and restart limits match; Host does not spawnTorrentarr.Workers. - Arch —
db_lock.py: WAL +SaveChangesWithRetryAsyncinstead of a cross-process file lock. - Arch — weekly-build / packaging: Docker/.NET vs pip; weekly Dependabot squash matches qBitrr (shared upstream behavior).
- Workers project: standalone loop lacks Host sync/RSS/refresh; unused in production Host (
SearchMissingis still gated there so it does not diverge).
Intentional (keep): +1 major version, torrentarr.db, Serilog, Docker/.NET vs pip, no db_lock, no pathos, no placeholder queues, weekly-build matching qBitrr, OpenAPI generated from qBitrr.
10. Follow-up work¶
The runtime drift items from the previous review are implemented. Remaining work is documentation hygiene as qBitrr’s arss/ package continues to split, and keeping test counts in certification-report.md current after each pass.