{ pkgs, config, service_configs, lib, inputs, ... }: let categoriesFile = pkgs.writeText "categories.json" ( builtins.toJSON (lib.mapAttrs (_: path: { save_path = path; }) service_configs.torrent.categories) ); in { imports = [ (lib.serviceMountWithZpool "qbittorrent" service_configs.zpool_hdds [ service_configs.torrents_path ]) (lib.serviceMountWithZpool "qbittorrent" service_configs.zpool_ssds [ "${config.services.qbittorrent.profileDir}/qBittorrent" ]) (lib.vpnNamespaceOpenPort config.services.qbittorrent.webuiPort "qbittorrent") (lib.serviceFilePerms "qbittorrent" [ # 0770: group (media) needs write to delete files during upgrades — # Radarr/Sonarr must unlink the old file before placing the new one. # Non-recursive (z not Z): UMask=0007 ensures new files get correct perms. # A recursive Z rule would walk millions of files on the HDD pool at every boot. "z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.SavePath} 0770 ${config.services.qbittorrent.user} ${service_configs.media_group}" "z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.TempPath} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}" "Z ${config.services.qbittorrent.profileDir} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}" ]) (lib.mkCaddyReverseProxy { subdomain = "torrent"; port = service_configs.ports.private.torrent.port; auth = true; vpn = true; }) ]; services.qbittorrent = { enable = true; webuiPort = service_configs.ports.private.torrent.port; profileDir = "/var/lib/qBittorrent"; # Set the service group to 'media' so the systemd unit runs with media as # the primary GID. Linux assigns new file ownership from the process's GID # (set by systemd's Group= directive), not from /etc/passwd. Without this, # downloads land as qbittorrent:qbittorrent (0700), blocking Radarr/Sonarr. group = service_configs.media_group; serverConfig.LegalNotice.Accepted = true; serverConfig.Preferences = { WebUI = { AlternativeUIEnabled = true; RootFolder = "${pkgs.vuetorrent}/share/vuetorrent"; # disable auth because we use caddy for auth AuthSubnetWhitelist = "0.0.0.0/0"; AuthSubnetWhitelistEnabled = true; }; Downloads = { inherit (service_configs.torrent) SavePath TempPath; }; }; serverConfig.BitTorrent = { Session = { MaxConnectionsPerTorrent = 100; MaxUploadsPerTorrent = 50; MaxConnections = -1; MaxUploads = -1; MaxActiveCheckingTorrents = 2; # queueing QueueingSystemEnabled = true; MaxActiveDownloads = 15; MaxActiveUploads = -1; MaxActiveTorrents = -1; IgnoreSlowTorrentsForQueueing = true; GlobalUPSpeedLimit = 0; GlobalDLSpeedLimit = 0; # Alternate speed limits for when Jellyfin is streaming AlternativeGlobalUPSpeedLimit = 500; # 500 KB/s when throttled AlternativeGlobalDLSpeedLimit = 800; # 800 KB/s when throttled IncludeOverheadInLimits = true; GlobalMaxRatio = 7.0; AddTrackersEnabled = true; AdditionalTrackers = lib.concatStringsSep "\\n" ( lib.lists.filter (x: x != "") ( lib.strings.splitString "\n" (builtins.readFile "${inputs.trackerlist}/trackers_all.txt") ) ); AnnounceToAllTrackers = true; # idk why it also has to be specified here too? inherit (config.services.qbittorrent.serverConfig.Preferences.Downloads) TempPath; TempPathEnabled = true; ConnectionSpeed = 200; # half-open connections/s; faster peer discovery SaveResumeDataInterval = 300; # save resume data every 5 min (default 60s) ResumeDataStorageType = "SQLite"; # SQLite is more efficient than legacy per-file .fastresume storage # Automatic Torrent Management: use category save paths for new torrents DisableAutoTMMByDefault = false; DisableAutoTMMTriggers.CategorySavePathChanged = false; DisableAutoTMMTriggers.DefaultSavePathChanged = false; ChokingAlgorithm = "RateBased"; SeedChokingAlgorithm = "FastestUpload"; # unchoke peers we upload to fastest PieceExtentAffinity = true; SuggestMode = true; # POSIX-compliant disk I/O: uses pread/pwrite instead of mmap. # On ZFS, mmap forces data into BOTH ARC and Linux page cache (double-caching), # wasting RAM. pread/pwrite goes only through ARC, maximizing its effectiveness. DiskIOType = "Posix"; FilePoolSize = 500; # keep more files open to reduce open/close overhead AioThreads = 24; # 6 cores * 4; better disk I/O parallelism # Send buffer watermarks control how much upload data qBittorrent pre-reads # from disk per peer. Pushed well above libtorrent defaults (and above # high_performance_seed's 3 MiB) to let the disk I/O thread issue larger, # less-frequent preadv() calls. Larger watermarks give ZFS's vdev aggregator # (4 MiB limit, 128 KiB gap) more contiguous requests to merge per sweep. # Memory cost is roughly watermark × active-peer-count; 6 MiB × a few hundred # peers is well within muffin's RAM budget. SendBufferLowWatermark = 512; # 512 KiB -- trigger reads sooner to prevent upload stalls SendBufferWatermark = 6144; # 6 MiB -- bigger pre-reads per peer than high_performance_seed's 3 MiB SendBufferWatermarkFactor = 200; # percent -- scale watermark more aggressively with upload rate # Maximum outstanding block requests per peer (libtorrent max_out_request_queue). # Default 500. Tripled so libtorrent's disk I/O thread sees deeper per-peer # request queues, giving it more contiguous blocks to coalesce into single # preadv() calls before issuing them to the kernel. RequestQueueSize = 1500; }; Network = { # traffic is routed through a vpn, we don't need # port forwarding PortForwardingEnabled = false; }; Session.UseUPnP = false; }; }; systemd.services.qbittorrent.serviceConfig = { # Default UMask=0022 creates files as 0644 (group read-only). With 0007, # new files get 0660/0770 so the media group has read+write immediately # instead of relying on the tmpfiles Z rule to fix permissions at restart. UMask = lib.mkForce "0007"; }; # Pre-define qBittorrent categories with explicit save paths so every # torrent routes to its category directory instead of the SavePath root. systemd.tmpfiles.settings.qbittorrent-categories = { "${config.services.qbittorrent.profileDir}/qBittorrent/config/categories.json"."L+" = { argument = "${categoriesFile}"; user = config.services.qbittorrent.user; group = config.services.qbittorrent.group; mode = "1400"; }; }; # Ensure category directories exist with correct ownership before first use. systemd.tmpfiles.rules = lib.mapAttrsToList ( _: path: "d ${path} 0770 ${config.services.qbittorrent.user} ${service_configs.media_group} -" ) service_configs.torrent.categories; # Periodically checkpoint qBittorrent's SQLite WAL (Write-Ahead Log). # qBittorrent holds a read transaction open for its entire lifetime, # preventing SQLite's auto-checkpoint from running. The WAL grows # unbounded (observed: 405 MB) and must be replayed on next startup, # causing 10+ minute "internal preparations" hangs. # A second sqlite3 connection can checkpoint concurrently and safely. # See: https://github.com/qbittorrent/qBittorrent/issues/20433 systemd.services.qbittorrent-wal-checkpoint = { description = "Checkpoint qBittorrent SQLite WAL"; after = [ "qbittorrent.service" ]; requires = [ "qbittorrent.service" ]; serviceConfig = { Type = "oneshot"; ExecStart = "${pkgs.sqlite}/bin/sqlite3 ${config.services.qbittorrent.profileDir}/qBittorrent/data/torrents.db 'PRAGMA wal_checkpoint(TRUNCATE);'"; User = config.services.qbittorrent.user; Group = config.services.qbittorrent.group; }; }; systemd.timers.qbittorrent-wal-checkpoint = { description = "Periodically checkpoint qBittorrent SQLite WAL"; wantedBy = [ "timers.target" ]; timerConfig = { OnUnitActiveSec = "4h"; OnBootSec = "30min"; RandomizedDelaySec = "10min"; }; }; users.users.${config.services.qbittorrent.user}.extraGroups = [ service_configs.media_group ]; }