{ config , lib , pkgs , ... }: let cfg = config.services.cross-seed; inherit (lib) mkOption mkEnableOption mkPackageOption mkIf types; configFile = pkgs.writeText "config.js" '' module.exports = { apiKey: process.env.CROSS_SEED_API_KEY || ${cfg.settings.apiKey}, torznab: process.env.CROSS_SEED_TORZNAB ? process.env.CROSS_SEED_TORZNAB.split(',') : ${cfg.settings.torznab}, sonarr: process.env.CROSS_SEED_SONARR ? process.env.CROSS_SEED_SONARR.split(',') : ${cfg.settings.sonarr}, radarr: process.env.CROSS_SEED_RADARR ? process.env.CROSS_SEED_RADARR.split(',') : ${cfg.settings.radarr}, host: process.env.CROSS_SEED_HOST || ${cfg.settings.host}, port: parseInt(process.env.CROSS_SEED_PORT) || ${toString cfg.settings.port}, notificationWebhookUrl: process.env.CROSS_SEED_NOTIFICATION_WEBHOOK_URL || ${cfg.settings.notificationWebhookUrl}, rtorrentRpcUrl: process.env.CROSS_SEED_RTORRENT_RPC_URL || ${cfg.settings.rtorrentRpcUrl}, qbittorrentUrl: process.env.CROSS_SEED_QBITTORRENT_URL || ${cfg.settings.qbittorrentUrl}, transmissionRpcUrl: process.env.CROSS_SEED_TRANSMISSION_RPC_URL || ${cfg.settings.transmissionRpcUrl}, delugeRpcUrl: process.env.CROSS_SEED_DELUGE_RPC_URL || ${cfg.settings.delugeRpcUrl}, delay: parseInt(process.env.CROSS_SEED_DELAY) || ${toString cfg.settings.delay}, dataDirs: process.env.CROSS_SEED_DATA_DIRS ? process.env.CROSS_SEED_DATA_DIRS.split(',') : ${cfg.settings.dataDirs}, linkCategory: process.env.CROSS_SEED_LINK_CATEGORY || "${cfg.settings.linkCategory}", linkDir: process.env.CROSS_SEED_LINK_DIR || ${cfg.settings.linkDir}, linkType: process.env.CROSS_SEED_LINK_TYPE || "${cfg.settings.linkType}", flatLinking: process.env.CROSS_SEED_FLAT_LINKING ? process.env.CROSS_SEED_FLAT_LINKING === 'true' : ${cfg.settings.flatLinking}, matchMode: process.env.CROSS_SEED_MATCH_MODE || "${cfg.settings.matchMode}", maxDataDepth: parseInt(process.env.CROSS_SEED_MAX_DATA_DEPTH) || ${toString cfg.settings.maxDataDepth}, torrentDir: process.env.CROSS_SEED_TORRENT_DIR || ${cfg.settings.torrentDir}, outputDir: process.env.CROSS_SEED_OUTPUT_DIR || "${cfg.settings.outputDir}", includeSingleEpisodes: process.env.CROSS_SEED_INCLUDE_SINGLE_EPISODES ? process.env.CROSS_SEED_INCLUDE_SINGLE_EPISODES === 'true' : ${cfg.settings.includeSingleEpisodes}, includeNonVideos: process.env.CROSS_SEED_INCLUDE_NON_VIDEOS ? process.env.CROSS_SEED_INCLUDE_NON_VIDEOS === 'true' : ${cfg.settings.includeNonVideos}, fuzzySizeThreshold: parseFloat(process.env.CROSS_SEED_FUZZY_SIZE_THRESHOLD) || ${toString cfg.settings.fuzzySizeThreshold}, excludeOlder: process.env.CROSS_SEED_EXCLUDE_OLDER || ${cfg.settings.excludeOlder}, excludeRecentSearch: process.env.CROSS_SEED_EXCLUDE_RECENT_SEARCH || ${cfg.settings.excludeRecentSearch}, action: process.env.CROSS_SEED_ACTION || "${cfg.settings.action}", duplicateCategories: process.env.CROSS_SEED_DUPLICATE_CATEGORIES ? process.env.CROSS_SEED_DUPLICATE_CATEGORIES === 'true' : ${cfg.settings.duplicateCategories}, rssCadence: process.env.CROSS_SEED_RSS_CADENCE || ${cfg.settings.rssCadence}, searchCadence: process.env.CROSS_SEED_SEARCH_CADENCE || ${cfg.settings.searchCadence}, snatchTimeout: process.env.CROSS_SEED_SNATCH_TIMEOUT || ${cfg.settings.snatchTimeout}, searchTimeout: process.env.CROSS_SEED_SEARCH_TIMEOUT || ${cfg.settings.searchTimeout}, searchLimit: parseInt(process.env.CROSS_SEED_SEARCH_LIMIT) || ${toString cfg.settings.searchLimit}, blockList: process.env.CROSS_SEED_BLOCK_LIST ? process.env.CROSS_SEED_BLOCK_LIST.split(',') : ${cfg.settings.blockList}, }; ''; jsBool = v: if v then "true" else "false"; jsStrUndefined = v: if v != null then "'${v}'" else "undefined"; jsStrNull = v: if v != null then "'${v}'" else "null"; jsListStrUndefined = v: if v != [] then "[${lib.concatMapStringsSep ", " (x: "'${x}'") v}]" else "undefined"; in { options.services.cross-seed = { enable = mkEnableOption "cross-seed"; package = mkPackageOption pkgs "cross-seed" { }; dataDir = mkOption { type = types.path; default = "/var/lib/cross-seed"; description = '' Path to a folder that will contain cross-seed working directory. ''; }; user = mkOption { type = types.str; default = "cross-seed"; description = lib.mdDoc "User account under which cross-seed runs."; }; group = mkOption { type = types.str; default = "cross-seed"; description = lib.mdDoc "Group under which cross-seed runs."; }; environmentFile = mkOption { type = types.nullOr types.path; default = null; description = '' Path to an environment file containing configuration values. This file can contain the following variables: Sensitive Information: * `CROSS_SEED_API_KEY`: Override the autogenerated API key * `CROSS_SEED_TORZNAB`: Comma-separated Torznab URLs with apikey included * `CROSS_SEED_SONARR`: Comma-separated Sonarr API URLs with apikey * `CROSS_SEED_RADARR`: Comma-separated Radarr API URLs with apikey * `CROSS_SEED_NOTIFICATION_WEBHOOK_URL`: Webhook URL for notifications * `CROSS_SEED_RTORRENT_RPC_URL`: rTorrent XMLRPC URL (with credentials) * `CROSS_SEED_QBITTORRENT_URL`: qBittorrent WebUI URL (with credentials) * `CROSS_SEED_TRANSMISSION_RPC_URL`: Transmission RPC URL (with credentials) * `CROSS_SEED_DELUGE_RPC_URL`: Deluge JSON-RPC URL (with credentials) Network Configuration: * `CROSS_SEED_HOST`: Bind address (e.g., "127.0.0.1") * `CROSS_SEED_PORT`: Listen port (default: 2468) Directory Settings: * `CROSS_SEED_DATA_DIRS`: Comma-separated paths to content directories * `CROSS_SEED_LINK_DIR`: Directory for creating matched file links * `CROSS_SEED_TORRENT_DIR`: Directory containing .torrent files * `CROSS_SEED_OUTPUT_DIR`: Directory to save found torrents Linking Configuration: * `CROSS_SEED_LINK_CATEGORY`: Category for linked torrents (default: "cross-seed-link") * `CROSS_SEED_LINK_TYPE`: Type of links to create ("symlink" or "hardlink") * `CROSS_SEED_FLAT_LINKING`: Use flat linking structure ("true" or "false") Matching Behavior: * `CROSS_SEED_MATCH_MODE`: Matching strictness ("safe", "risky", or "partial") * `CROSS_SEED_MAX_DATA_DEPTH`: Max directory depth for searching * `CROSS_SEED_INCLUDE_SINGLE_EPISODES`: Include single episodes ("true" or "false") * `CROSS_SEED_INCLUDE_NON_VIDEOS`: Include non-video content ("true" or "false") * `CROSS_SEED_FUZZY_SIZE_THRESHOLD`: Size difference threshold (e.g., "0.02") * `CROSS_SEED_BLOCK_LIST`: Comma-separated list of excluded terms or infohashes Timing and Limits: * `CROSS_SEED_DELAY`: Minimum seconds between searches * `CROSS_SEED_EXCLUDE_OLDER`: Exclude torrents older than (e.g., "2 weeks") * `CROSS_SEED_EXCLUDE_RECENT_SEARCH`: Exclude recently searched (e.g., "3 days") * `CROSS_SEED_RSS_CADENCE`: RSS scan schedule (e.g., "30 minutes") * `CROSS_SEED_SEARCH_CADENCE`: Search schedule (e.g., "1 day") * `CROSS_SEED_SNATCH_TIMEOUT`: Snatch request timeout (e.g., "30 seconds") * `CROSS_SEED_SEARCH_TIMEOUT`: Search request timeout (e.g., "2 minutes") * `CROSS_SEED_SEARCH_LIMIT`: Maximum searches per batch Client Integration: * `CROSS_SEED_ACTION`: Action on match ("save" or "inject") * `CROSS_SEED_DUPLICATE_CATEGORIES`: Use original categories ("true" or "false") Example: ``` CROSS_SEED_API_KEY=your-api-key CROSS_SEED_TORZNAB=http://indexer1/api?apikey=key1,http://indexer2/api?apikey=key2 CROSS_SEED_QBITTORRENT_URL=http://username:password@localhost:8080 CROSS_SEED_DATA_DIRS=/downloads/movies,/downloads/tv ``` ''; }; settings = { torznab = mkOption { apply = v: if v != null then "[${lib.concatMapStringsSep ", " (x: "'${x}'") v}]" else "undefined"; type = types.nullOr (types.listOf types.str); default = null; example = [ "http://localhost:9696/1/api?apikey=1234" ]; description = "Torznab URLs with apikey included"; }; dataDirs = mkOption { apply = v: if v != [] then "[${lib.concatMapStringsSep ", " (x: "'${x}'") v}]" else "[]"; type = types.listOf types.path; default = [ ]; example = [ "/downloads/movies" "/downloads/packs" ]; description = "Directories to use if searching by data instead of torrents"; }; matchMode = mkOption { type = types.enum [ "safe" "risky" "partial" ]; default = "safe"; description = "Matching mode for torrent comparison"; }; linkCategory = mkOption { type = types.str; default = "cross-seed-link"; description = "Torrent client category for linked torrents"; }; linkDir = mkOption { apply = jsStrUndefined; type = types.nullOr types.path; default = null; description = "If this is specified, cross-seed will create links to matched files in the specified directory. It will create a different link for every changed file name or directory structure."; }; flatLinking = mkOption { apply = jsBool; type = types.bool; default = false; description = "Use flat linking directory structure"; }; linkType = mkOption { type = types.enum [ "symlink" "hardlink" ]; default = "symlink"; description = "Type of links for data-based matches"; }; maxDataDepth = mkOption { type = types.int; default = 2; description = "Max depth to look for searchees in dataDirs"; }; torrentDir = mkOption { apply = jsStrUndefined; type = types.nullOr types.path; default = null; example = "~/.local/share/qBittorrent/BT_backup"; description = "Directory containing .torrent files"; }; outputDir = mkOption { type = types.path; default = "${cfg.dataDir}/output"; example = "/cross-seeds"; description = "Where to save the torrent files that cross-seed finds for you."; }; includeNonVideos = mkOption { apply = jsBool; type = types.bool; default = false; description = "Include torrents which contain non-video files"; }; includeSingleEpisodes = mkOption { apply = jsBool; type = types.bool; default = false; description = "Include single episode torrents in the search"; }; fuzzySizeThreshold = mkOption { type = types.float; default = 0.02; description = "You should NOT modify this unless you have good reason. The following option is the preliminary value to compare sizes of releases for further comparison."; }; excludeOlder = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "2 weeks"; description = "Exclude torrents first seen more than n minutes ago"; }; excludeRecentSearch = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "3 days"; description = "Exclude torrents searched more recently than n minutes ago"; }; verbose = mkOption { type = types.bool; default = false; description = "Enable verbose logging"; }; action = mkOption { type = types.enum [ "save" "inject" ]; default = "save"; description = "Action to take with found torrents"; }; rtorrentRpcUrl = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "http://username:password@localhost:1234/RPC2"; description = "rTorrent XMLRPC interface URL"; }; qbittorrentUrl = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "http://username:password@localhost:8080"; description = "qBittorrent WebUI URL"; }; transmissionRpcUrl = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "http://username:password@localhost:9091/transmission/rpc"; description = "Transmission RPC interface URL"; }; delugeRpcUrl = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "http://:password@localhost:8112/json"; description = "Deluge JSON-RPC interface URL"; }; duplicateCategories = mkOption { apply = jsBool; type = types.bool; default = false; description = "Create and inject using categories with same save paths"; }; notificationWebhookUrl = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; description = "Webhook URL for notifications. Conforms to the caronc/apprise REST api."; }; delay = mkOption { type = types.int; default = 30; description = "Pause duration (seconds) between searches"; }; snatchTimeout = mkOption { apply = jsStrNull; type = types.str; default = "30 seconds"; description = "Timeout for unresponsive snatches"; }; searchTimeout = mkOption { apply = jsStrNull; type = types.str; default = "30 seconds"; description = "Timeout for unresponsive searches"; }; searchLimit = mkOption { type = types.int; default = 0; description = "Number of searches before stopping (0 for unlimited)"; }; blockList = mkOption { apply = jsListStrUndefined; type = types.listOf types.str; default = [ ]; example = [ "-excludedGroup" "-excludedGroup2" "x265" "Release.Name" "3317e6485454354751555555366a8308c1e92093" ]; description = "Infohashes and/or strings in torrent names to block"; }; sonarr = mkOption { apply = jsListStrUndefined; type = types.listOf types.str; default = [ ]; example = [ "http://sonarr:8989/?apikey=12345" ]; description = "Sonarr API URLs"; }; radarr = mkOption { apply = jsListStrUndefined; type = types.listOf types.str; default = [ ]; example = [ "http://radarr:7878/?apikey=12345" ]; description = "Radarr API URLs"; }; port = mkOption { type = types.nullOr types.port; default = 2468; description = "Port to listen on"; }; host = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = "0.0.0.0"; example = "127.0.0.1"; description = "IP address to bind to"; }; searchCadence = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "1 day"; description = "Schedule for running searches (https://github.com/vercel/ms format)"; }; rssCadence = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; example = "30 minutes"; description = "Schedule for running RSS scans (https://github.com/vercel/ms format)"; }; apiKey = mkOption { apply = jsStrUndefined; type = types.nullOr types.str; default = null; description = "Provide your own API key here to override the autogenerated one."; }; }; }; config = mkIf cfg.enable { users.groups = mkIf (cfg.group == "cross-seed") { cross-seed = { }; }; users.users = mkIf (cfg.user == "cross-seed") { cross-seed = { description = "cross-seed service user"; group = cfg.group; home = cfg.dataDir; createHome = true; isSystemUser = true; }; }; systemd.services.cross-seed = { enable = true; description = "cross-seed in daemon mode"; wantedBy = [ "multi-user.target" ]; preStart = '' mkdir -p ${cfg.settings.outputDir} rm /var/lib/cross-seed/config.js || true ln -s ${configFile} /var/lib/cross-seed/config.js ''; serviceConfig = { User = cfg.user; Group = cfg.group; Environment = [ "CONFIG_DIR=${cfg.dataDir}" ]; EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; WorkingDirectory = cfg.dataDir; ExecStart = "${lib.getExe cfg.package} daemon"; }; }; }; }