Downloader service pipeline
The downloader service manages the full lifecycle of a torrent download: adding it to qBittorrent, polling progress, organising completed files into the media library, and notifying Jellyfin.
Startup
- Database connection initialised (same PostgreSQL as core).
- qBittorrent provider self-registers via
init()(blank-imported incmd/downloader/main.go). PollAndDownloadstarts in a background goroutine.- Internal HTTP server starts on port
8083.
Poll loop
Runs every downloaderInterval seconds (default 5 s). Each cycle runs two passes:
Pass 1 — ProcessPending
Picks up entries in the download queue that haven't been submitted to qBittorrent yet.
SELECT from download_queue WHERE status = 'pending'
│
▼
Login to qBittorrent (session cookie reused across cycles)
│
▼
Resolve torrent URL
├── Magnet link → pass through directly
└── HTTP URL → fetch bytes, detect XML error responses (some indexers
redirect to an error page instead of 404ing)
│
▼
POST /api/v2/torrents/add to qBittorrent
│
▼
Poll qBittorrent for the torrent hash (up to 30 s)
│
▼
Update download_queue: status = 'downloading', torrent_hash = '...'
Pass 2 — UpdateDownloading
Checks progress on all active downloads and handles completion or stalls.
SELECT from download_queue WHERE status = 'downloading'
│
▼
Login to qBittorrent
│
▼
FetchTorrents by hash
│
├── Not found in qBittorrent → mark entry as error
│
├── progress < 1.0
│ ├── Check stallTimeout: if no progress for N seconds → blacklist
│ │ torrent, delete from qBittorrent, reset monitor to 'pending'
│ └── Update progress in DB
│
└── progress = 1.0 → onComplete()
Completion flow (onComplete)
Download complete
│
▼
Resolve monitor → media_folder (from media table)
│
▼
Walk download save_path recursively
For each video file (extension in allowedVideoExtensions):
├── Parse filename with anitogo → season, episode, title
├── Determine destination: {mediaPath}/{show}/{Title} - S01E05.mkv
└── os.Link(src, dst) ← hardlink (requires same filesystem)
│
▼
If no video files found → blacklist torrent, delete from qBittorrent,
reset monitor to 'pending' (try a different release)
│
▼
Write tvshow.nfo to {mediaPath}/{show}/tvshow.nfo (Jellyfin AniDB match)
<?xml version="1.0" encoding="utf-8"?>
<tvshow>
<title>Steins;Gate</title>
<uniqueid type="anidb" default="true">6987</uniqueid>
</tvshow>
│
▼
POST {jellyfinUrl}/Library/Refresh (if jellyfinUrl is configured)
│
▼
Update download_queue: status = 'completed'
Update monitor: status = 'downloaded'
Stall detection
For each downloading entry, the downloader records progress_updated_at on progress change. After stallTimeout seconds with no change, the torrent is considered stalled:
- Torrent hash added to
torrent_blacklist - Torrent deleted from qBittorrent
- Download queue entry soft-deleted
- Monitor reset to
status = 'pending'so the indexer searches for a different release
Set stallTimeout = 0 to disable stall detection entirely.
Hardlink vs copy
kbarr organises files with os.Link (hardlinks), not copies or symlinks. This means:
- Zero extra disk space
- The original torrent file and the organised file share the same inode
- Source and destination must be on the same filesystem: if
downloadPathandmediaPathare on different volumes this fails silently
Key packages
| Package | Role |
|---|---|
internal/downloader/provider/ | TorrentClient interface, provider registry |
internal/downloader/provider/qbittorrent/ | qBittorrent HTTP client implementation |
internal/downloader/service/ | Poll loop, hardlink creation, NFO writing, Jellyfin trigger |
internal/naming/ | SanitizeFilename — strips filesystem-unsafe characters while preserving Unicode (Japanese titles etc.) |