NixOS / nixpkgs

Nix Packages collection & NixOS
MIT License
17.32k stars 13.56k forks source link

Package request: cross-seed #289917

Open rasmus-kirk opened 6 months ago

rasmus-kirk commented 6 months ago

Project description

cross-seed is a project that allows sharing the seeding of torrents between trackers.

Metadata

Add a :+1: reaction to issues you find important.

ShaddyDC commented 1 month ago

I'd started trying to package it and making a service out of it too a while ago, but I never got around to actually finish it or even test it. It might look something like this though.

Derivation + service ```nix { pkgs, config, lib, ... }: with lib; let cfg = config.services.cross-seed; configFileContent = builtins.toJSON cfg.settings; configFile = pkgs.writeText "config.js" '' "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); module.exports = ${configFileContent}; ''; cross-seed = with pkgs; buildNpmPackage rec { pname = "cross-seed"; version = "5.9.1"; src = fetchFromGitHub { owner = "cross-seed"; repo = "cross-seed"; rev = "refs/tags/v${version}"; hash = "sha256-kxJafaU4Ih6zUwy2xjjLBro/MQ6M0rtp7Imx+Ut3CYs="; }; npmDepsHash = "sha256-vexAGEhgBUb42ReWORaf0MqLQ2Txz0nl4eJ5f34Tm68="; }; in { options.services.cross-seed = { enable = mkEnableOption "Cross Seed service"; home = mkOption { type = types.path; default = "/var/lib/cross-seed"; }; 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 account under which cross-seed runs."; }; settings = mkOption { default = {}; type = types.submodule { freeformType = (pkgs.formats.json {}).type; options = { delay = mkOption { type = types.int; default = 10; description = "Pause at least this much in between each search. Higher is safer."; }; torznab = mkOption { type = types.listOf types.str; default = []; description = "List of Torznab URLs."; }; dataDirs = mkOption { type = types.nullOr (types.listOf types.path); default = null; description = "Directories to your downloaded torrent data."; }; matchMode = mkOption { type = types.enum ["safe" "risky"]; default = "safe"; description = "Determines flexibility of naming during matching."; }; dataCategory = mkOption { type = types.nullOr types.str; default = null; description = "Defines what category torrents injected by data-based matching should use."; }; linkDir = mkOption { type = types.nullOr types.path; default = null; description = "Directory for creating links to scanned files."; }; linkType = mkOption { type = types.enum ["symlink" "hardlink"]; default = "symlink"; description = "Type of links to use for data-based matches."; }; skipRecheck = mkOption { type = types.bool; default = false; description = "Whether to skip recheck in Qbittorrent."; }; maxDataDepth = mkOption { type = types.int; default = 2; description = "Determines how deep into the specified dataDirs to go to generate new searchees."; }; torrentDir = mkOption { type = types.str; default = "/path/to/torrent/file/dir"; description = "Directory containing .torrent files."; }; outputDir = mkOption { type = types.str; default = "."; description = "Where to put the torrent files that cross-seed finds for you."; }; includeEpisodes = mkOption { type = types.bool; default = false; description = "Whether to search for all episode torrents, including those from season packs."; }; includeSingleEpisodes = mkOption { type = types.bool; default = false; description = "Whether to include single episode torrents in the search."; }; includeNonVideos = mkOption { type = types.bool; default = false; description = "Include torrents which contain non-video files."; }; fuzzySizeThreshold = mkOption { type = types.float; default = 0.02; description = "Fuzzy size match threshold as a decimal value."; }; excludeOlder = mkOption { type = types.nullOr types.str; default = null; description = "Exclude torrents first seen more than this long ago."; }; excludeRecentSearch = mkOption { type = types.nullOr types.str; default = null; description = "Exclude torrents which have been searched more recently than this."; }; action = mkOption { type = types.enum ["save" "inject"]; default = "save"; description = "With 'inject' you need to set up one of the specified clients."; }; rtorrentRpcUrl = mkOption { type = types.nullOr types.str; default = null; description = "The URL of your rtorrent XMLRPC interface."; }; qbittorrentUrl = mkOption { type = types.nullOr types.str; default = null; description = "The URL of your qBittorrent webui."; }; transmissionRpcUrl = mkOption { type = types.nullOr types.str; default = null; description = "The URL of your Transmission RPC interface."; }; delugeRpcUrl = mkOption { type = types.nullOr types.str; default = null; description = "The URL of your Deluge JSON-RPC interface."; }; duplicateCategories = mkOption { type = types.bool; default = false; description = "Whether to inject using the same labels/categories as the original torrent."; }; notificationWebhookUrl = mkOption { type = types.nullOr types.str; default = null; description = "URL for POST requests with JSON payload of { title, body }."; }; port = mkOption { type = types.int; default = 2468; description = "Listen on a custom port."; }; host = mkOption { type = types.nullOr types.str; default = null; description = "Bind to a specific host address."; }; apiAuth = mkOption { type = types.bool; default = true; description = "Whether to require authentication for API."; }; rssCadence = mkOption { type = types.nullOr types.str; default = null; description = "Run RSS scans on a schedule."; }; searchCadence = mkOption { type = types.nullOr types.str; default = null; description = "Run searches on a schedule."; }; snatchTimeout = mkOption { type = types.nullOr types.str; default = null; description = "Fail snatch requests that haven't responded after this long."; }; searchTimeout = mkOption { type = types.nullOr types.str; default = null; description = "Fail search requests that haven't responded after this long."; }; searchLimit = mkOption { type = types.nullOr types.int; default = null; description = "The number of searches to be done before it stops."; }; }; }; }; }; config = mkIf cfg.enable { systemd.services.cross-seed = { enable = true; description = "cross-seed daemon"; after = ["network.target"]; wantedBy = ["multi-user.target"]; requires = ["transmission.service"]; serviceConfig = { User = "username"; # Replace with actual username Group = "groupname"; # Replace with actual groupname Environment = "CONFIG_DIR=${cfg.home}"; ExecStart = "${cross-seed}/bin/cross-seed daemon"; Restart = "always"; Type = "simple"; ExecStartPre = [ ("+" + pkgs.writeShellScript "cross-seed-prestart" '' set -eu install -D -m 600 -o '${cfg.user}' -g '${cfg.group}' \ '${configFile}' '${cfg.home}/config.js' '') ]; }; # preStart = '' # mkdir -p ${cfg.home} # ln -sf ${configFile}${cfg.home}/config.js # ''; # Use "+" because credentialsFile may not be accessible to User= or Group=. }; }; } ```
rasmus-kirk commented 1 month ago

I also packaged it for a Nixarr, doing much of the same, I do like yours a little better though. Nevertheless, I'll post mine here as well. Package:

{
  lib,
  buildNpmPackage,
  fetchFromGitHub,
}:
buildNpmPackage rec {
  pname = "cross-seed";
  version = "5.9.2";

  src = fetchFromGitHub {
    owner = "cross-seed";
    repo = pname;
    rev = "v${version}";
    hash = "sha256-E0AlsFV9RP01YVwjw6ZQ8Lf1IVyuudxrb5oJ61EfIyo=";
  };

  npmDepsHash = "sha256-hZKLv+bzRFiMjNemydCUC1d7xul7Mm+vOPtCUD7p9XQ=";

  meta = with lib; {
    description = "cross-seed is an app designed to help you download torrents that you can cross seed based on your existing torrents";
    homepage = "https://www.cross-seed.org";
    license = licenses.asl20;
  };
}

Service:

{
  config,
  pkgs,
  lib,
  ...
}:
with lib; let
  cfg = config.util-nixarr.services.cross-seed;
  settingsFormat = pkgs.formats.json {};
  settingsFile = settingsFormat.generate "settings.json" cfg.settings;
  cross-seedPkg = pkgs.callPackage ../../../pkgs/cross-seed {};
  configJs = pkgs.writeText "config.js" ''
    // Loads a json.config
    "use strict";
    const fs = require('fs');

    const jsonPath = '${cfg.dataDir}/config.json'

    // Synchronously read the JSON-configuration file
    const configFileContent = fs.readFileSync(jsonPath, { encoding: 'utf8' });

    // Parse the JSON content into a JavaScript object
    let config = JSON.parse(configFileContent);

    // Function to recursively replace null values with undefined, should not be needed, but I was paranoid
    /*
    function replaceNullWithUndefined(obj) {
      Object.keys(obj).forEach(key => {
        if (obj[key] === null) {
          obj[key] = undefined;
        } else if (typeof obj[key] === 'object') {
          replaceNullWithUndefined(obj[key]);
        }
      });
    }
    replaceNullWithUndefined(config);
    */

    // Export the configuration object
    module.exports = config;
  '';
in {
  options = {
    util-nixarr.services.cross-seed = {
      enable = mkEnableOption "cross-seed";

      settings = mkOption {
        type = types.attrs;
        default = {};
        example = ''
          {
            delay = 10;
          }
        '';
        description = "cross-seed config"; # TODO: todo
      };

      dataDir = mkOption {
        type = types.path;
        default = "/var/lib/cross-seed";
        description = "cross-seed dataDir"; # TODO: todo
      };

      credentialsFile = mkOption {
        type = types.path;
        default = "/run/secrets/cross-seed/credentialsFile.json";
        description = "cross-seed dataDir"; # TODO: todo
      };

      user = mkOption {
        type = types.str;
        default = "cross-seed";
        description = "User account under which cross-seed runs.";
      };

      group = mkOption {
        type = types.str;
        default = "cross-seed";
        description = "Group under which cross-seed runs.";
      };
    };
  };

  config = mkIf cfg.enable {
    assertions = [
      {
        assertion = cfg.enable -> cfg.settings.outputDir != null;
        message = ''
          The settings.outputDir option must be set if cross-seed is enabled.
        '';
      }
      {
        assertion = cfg.enable -> cfg.settings.torrentDir != null;
        message = ''
          The settings.torrentDir option must be set if cross-seed is enabled.
        '';
      }
    ];

    systemd.tmpfiles.rules =
      [
        "L+ '${cfg.dataDir}'/config.js - - - - ${configJs}"
        "d '${cfg.dataDir}' 0700 ${cfg.user} ${cfg.group} - -"
      ]
      ++ (
        if cfg.settings.outputDir != null
        then ["d '${cfg.settings.outputDir}' 0755 ${cfg.user} ${cfg.group} - -"]
        else []
      );

    systemd.services.cross-seed = {
      description = "cross-seed";
      after = ["network.target"];
      wantedBy = ["multi-user.target"];

      environment.CONFIG_DIR = cfg.dataDir;

      serviceConfig = {
        # Run as root in case that the cfg.credentialsFile is not readable by cross-seed
        ExecStartPre = [
          (
            "+"
            + pkgs.writeShellScript "transmission-prestart" ''
              ${pkgs.jq}/bin/jq --slurp add ${settingsFile} '${cfg.credentialsFile}' |
              install -D -m 600 -o '${cfg.user}' /dev/stdin '${cfg.dataDir}/config.json'
            ''
          )
        ];
        Type = "simple";
        User = cfg.user;
        Group = cfg.group;
        ExecStart = "${cross-seedPkg}/bin/cross-seed daemon";
        Restart = "on-failure";
      };
    };

    users.users = mkIf (cfg.user == "cross-seed") {
      cross-seed = {
        isSystemUser = true;
        group = cfg.group;
      };
    };

    users.groups = mkIf (cfg.group == "cross-seed") {
      cross-seed = {};
    };
  };
}