WIP: Add cross-seed module #3
1 changed files with 447 additions and 0 deletions
447
modules/cross-seed.nix
Normal file
447
modules/cross-seed.nix
Normal file
|
@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue