diff --git a/modules/cross-seed.nix b/modules/cross-seed.nix new file mode 100644 index 0000000..e341f3b --- /dev/null +++ b/modules/cross-seed.nix @@ -0,0 +1,447 @@ +{ 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"; + }; + }; + }; +}