From ecaf765bb52c1b05276e3295882e915b904d8251 Mon Sep 17 00:00:00 2001
From: matt1432 <matt@nelim.org>
Date: Sat, 22 Mar 2025 19:30:22 -0400
Subject: [PATCH] feat: init kapowarr setup

---
 .../cluster/modules/caddy/default.nix         |   1 +
 configurations/nos/modules/comics/default.nix |   1 +
 .../nos/modules/comics/kapowarr/default.nix   |  11 ++
 .../nos/modules/comics/kapowarr/module.nix    | 116 ++++++++++++++
 packages/default.nix                          |   2 +
 packages/kapowarr/bencoding/default.nix       |  17 ++
 packages/kapowarr/default.nix                 |  14 ++
 packages/kapowarr/main/default.nix            | 145 ++++++++++++++++++
 packages/kapowarr/main/raise-errors.patch     |  26 ++++
 packages/kapowarr/tenacity/default.nix        |  49 ++++++
 .../kapowarr/typing-extensions/default.nix    |  23 +++
 11 files changed, 405 insertions(+)
 create mode 100644 configurations/nos/modules/comics/kapowarr/default.nix
 create mode 100644 configurations/nos/modules/comics/kapowarr/module.nix
 create mode 100644 packages/kapowarr/bencoding/default.nix
 create mode 100644 packages/kapowarr/default.nix
 create mode 100644 packages/kapowarr/main/default.nix
 create mode 100644 packages/kapowarr/main/raise-errors.patch
 create mode 100644 packages/kapowarr/tenacity/default.nix
 create mode 100644 packages/kapowarr/typing-extensions/default.nix

diff --git a/configurations/cluster/modules/caddy/default.nix b/configurations/cluster/modules/caddy/default.nix
index 4e4f153c..945e391d 100644
--- a/configurations/cluster/modules/caddy/default.nix
+++ b/configurations/cluster/modules/caddy/default.nix
@@ -147,6 +147,7 @@ in {
               prowlarr.reverseProxy = "${nosIP}:9696";
               radarr.reverseProxy = "${nosIP}:7878";
               sonarr.reverseProxy = "${nosIP}:8989";
+              kapowarr.reverseProxy = "${nosIP}:5676";
 
               jdownloader2 = {
                 subDirName = "jd2";
diff --git a/configurations/nos/modules/comics/default.nix b/configurations/nos/modules/comics/default.nix
index fdbb6e1a..4b2aa8a0 100644
--- a/configurations/nos/modules/comics/default.nix
+++ b/configurations/nos/modules/comics/default.nix
@@ -1,6 +1,7 @@
 {...}: {
   imports = [
     ./jdownloader2
+    ./kapowarr
     ./komga
   ];
 }
diff --git a/configurations/nos/modules/comics/kapowarr/default.nix b/configurations/nos/modules/comics/kapowarr/default.nix
new file mode 100644
index 00000000..4792821a
--- /dev/null
+++ b/configurations/nos/modules/comics/kapowarr/default.nix
@@ -0,0 +1,11 @@
+{mainUser, ...}: {
+  imports = [./module.nix];
+
+  services.kapowarr = {
+    enable = true;
+    port = 5676;
+
+    user = mainUser;
+    group = "users";
+  };
+}
diff --git a/configurations/nos/modules/comics/kapowarr/module.nix b/configurations/nos/modules/comics/kapowarr/module.nix
new file mode 100644
index 00000000..ba4891b5
--- /dev/null
+++ b/configurations/nos/modules/comics/kapowarr/module.nix
@@ -0,0 +1,116 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}: let
+  inherit
+    (lib)
+    getExe
+    mkEnableOption
+    mkIf
+    mkOption
+    mkPackageOption
+    types
+    ;
+
+  cfg = config.services.kapowarr;
+in {
+  options.services.kapowarr = {
+    enable = mkEnableOption "kapowarr";
+    package = mkPackageOption pkgs.selfPackages "kapowarr" {};
+
+    user = mkOption {
+      type = types.str;
+      default = "kapowarr";
+      description = "The user account under which Kapowarr runs.";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "kapowarr";
+      description = "The group under which Kapowarr runs.";
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5656;
+      description = "Port where kapowarr should listen for incoming requests.";
+    };
+
+    dataDir = mkOption {
+      type = types.path;
+      default = "/var/lib/kapowarr/";
+      description = "The directory where Kapowarr stores its data files.";
+    };
+
+    downloadDir = mkOption {
+      type = types.path;
+      default = "${cfg.dataDir}/temp_downloads";
+      defaultText = "/var/lib/kapowarr/temp_downloads";
+      description = "The directory where Kapowarr stores its downloaded files.";
+    };
+
+    logDir = mkOption {
+      type = types.path;
+      default = cfg.dataDir;
+      defaultText = "/var/lib/kapowarr";
+      description = "The directory where Kapowarr stores its log file.";
+    };
+
+    openFirewall = mkEnableOption "Open ports in the firewall for Kapowarr.";
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.kapowarr = {
+      description = "Kapowarr";
+      after = ["network.target"];
+      wantedBy = ["multi-user.target"];
+
+      environment = {
+        KAPOWARR_PORT = toString cfg.port;
+        KAPOWARR_LOG_DIR = cfg.logDir;
+        KAPOWARR_STATE_DIR = cfg.dataDir;
+        KAPOWARR_DOWNLOAD_DIR = cfg.downloadDir;
+      };
+
+      serviceConfig = {
+        Type = "simple";
+        User = cfg.user;
+        Group = cfg.group;
+        StateDirectory = mkIf (cfg.dataDir == "/var/lib/kapowar") "kapowarr";
+        ExecStart = "${getExe cfg.package} -d ${cfg.dataDir}";
+
+        # Hardening from komga service
+        RemoveIPC = true;
+        NoNewPrivileges = true;
+        CapabilityBoundingSet = "";
+        SystemCallFilter = ["@system-service"];
+        ProtectSystem = "full";
+        PrivateTmp = true;
+        ProtectProc = "invisible";
+        ProtectClock = true;
+        ProcSubset = "pid";
+        PrivateUsers = true;
+        PrivateDevices = true;
+        ProtectHostname = true;
+        ProtectKernelTunables = true;
+        RestrictAddressFamilies = [
+          "AF_INET"
+          "AF_INET6"
+          "AF_NETLINK"
+        ];
+        LockPersonality = true;
+        RestrictNamespaces = true;
+        ProtectKernelLogs = true;
+        ProtectControlGroups = true;
+        ProtectKernelModules = true;
+        SystemCallArchitectures = "native";
+        RestrictSUIDSGID = true;
+        RestrictRealtime = true;
+      };
+    };
+
+    networking.firewall = mkIf cfg.openFirewall {allowedTCPPorts = [cfg.port];};
+  };
+}
diff --git a/packages/default.nix b/packages/default.nix
index 8ea6ce80..97d84229 100644
--- a/packages/default.nix
+++ b/packages/default.nix
@@ -19,6 +19,8 @@
 
     jmusicbot = final.callPackage ./jmusicbot {};
 
+    kapowarr = import ./kapowarr final;
+
     komf = final.callPackage ./komf {};
 
     libratbag = final.callPackage ./libratbag {
diff --git a/packages/kapowarr/bencoding/default.nix b/packages/kapowarr/bencoding/default.nix
new file mode 100644
index 00000000..31f8f3a3
--- /dev/null
+++ b/packages/kapowarr/bencoding/default.nix
@@ -0,0 +1,17 @@
+{
+  # nix build inputs
+  buildPythonPackage,
+  fetchPypi,
+  ...
+}: let
+  pname = "bencoding";
+  version = "0.2.6";
+in
+  buildPythonPackage {
+    inherit pname version;
+
+    src = fetchPypi {
+      inherit pname version;
+      hash = "sha256-Q8zjHUhj4p1rxhFVHU6fJlK+KZXp1eFbRtg4PxgNREA=";
+    };
+  }
diff --git a/packages/kapowarr/default.nix b/packages/kapowarr/default.nix
new file mode 100644
index 00000000..0e142c2a
--- /dev/null
+++ b/packages/kapowarr/default.nix
@@ -0,0 +1,14 @@
+{
+  # nix build inputs
+  python3Packages,
+  ...
+}: let
+  pyPkgs = python3Packages.override {
+    overrides = pyFinal: pyPrev: {
+      bencoding = pyFinal.callPackage ./bencoding {};
+      tenacity = pyFinal.callPackage ./tenacity {};
+      typing-extensions = pyFinal.callPackage ./typing-extensions {};
+    };
+  };
+in
+  pyPkgs.callPackage ./main {}
diff --git a/packages/kapowarr/main/default.nix b/packages/kapowarr/main/default.nix
new file mode 100644
index 00000000..189b1905
--- /dev/null
+++ b/packages/kapowarr/main/default.nix
@@ -0,0 +1,145 @@
+{
+  # nix build inputs
+  lib,
+  buildPythonApplication,
+  fetchFromGitHub,
+  python,
+  # deps
+  aiohttp,
+  beautifulsoup4,
+  bencoding, # from overrides
+  flask,
+  flask-socketio,
+  pycryptodome,
+  requests,
+  setuptools,
+  simplejson,
+  tenacity, # from overrides
+  typing-extensions, # from overrides
+  waitress,
+  ...
+}: let
+  pname = "kapowarr";
+  version = "1.1.1";
+in
+  buildPythonApplication {
+    inherit pname version;
+
+    src = fetchFromGitHub {
+      owner = "Casvt";
+      repo = "Kapowarr";
+      rev = "V${version}";
+      hash = "sha256-EeDzgi37f0cA86lQ1Z6hzLgpE3ORfz0YPoMWp5R4uPs=";
+    };
+
+    patches = [./raise-errors.patch];
+
+    postPatch = ''
+      # Insert import for following substituteInPlace
+      sed -i '/# -\*- coding: utf-8 -\*-/a from os import environ' ./backend/base/logging.py
+
+      substituteInPlace ./backend/base/logging.py --replace-fail \
+          "return folder_path(Constants.LOGGER_FILENAME)" \
+          "return f\"{environ.get('KAPOWARR_LOG_DIR')}/{Constants.LOGGER_FILENAME}\""
+
+      substituteInPlace ./backend/internals/settings.py \
+          --replace-fail \
+              "from os import urandom" \
+              "from os import urandom, environ" \
+          --replace-fail \
+              "port: int = 5656" \
+              "port: int = int(environ.get('KAPOWARR_PORT'))" \
+          --replace-fail \
+              "download_folder: str = folder_path('temp_downloads')" \
+              "download_folder: str = environ.get('KAPOWARR_DOWNLOAD_DIR')" \
+          --replace-fail \
+              "filename = folder_path('frontend', 'static', 'json', 'pwa_manifest.json')" \
+              "filename = f\"{environ.get('KAPOWARR_STATE_DIR')}/pwa_manifest.json\""
+    '';
+
+    build-system = [setuptools];
+
+    dependencies = [
+      typing-extensions
+      requests
+      beautifulsoup4
+      flask
+      waitress
+      pycryptodome
+      tenacity
+      bencoding
+      simplejson
+      aiohttp
+      flask-socketio
+    ];
+
+    preBuild = ''
+      cat > setup.py << EOF
+      from setuptools import setup, find_packages, find_namespace_packages
+
+      with open('requirements.txt') as f:
+           install_requires = f.read().splitlines()
+
+      setup(
+        name='${pname}',
+        version = '${version}',
+        install_requires=install_requires,
+        packages=[
+          'frontend',
+          'backend',
+          'backend.base',
+          'backend.features',
+          'backend.implementations',
+          'backend.implementations.torrent_clients',
+          'backend.internals',
+          'backend.lib',
+        ],
+        scripts=[
+          'Kapowarr.py'
+        ],
+      )
+      EOF
+    '';
+
+    # Use XDG-ish dirs for configuration. These would otherwise be in the kapowarr package.
+    #
+    # Using --run as `makeWrapper` evaluates variables for --set and --set-default at build
+    # time and then single quotes the vars in the wrapper, thus they wouldn't get expanded.
+    # But using --run allows setting default vars that are  evaluated on run and not during
+    # build time.
+    makeWrapperArgs = [
+      "--set-default KAPOWARR_PORT 5656"
+      ''
+        --run "OUTDIR=\"$out\""
+        --run '
+        configDir="''${XDG_CONFIG_HOME:-$HOME/.config}/kapowarr"
+        export KAPOWARR_STATE_DIR="''${KAPOWARR_STATE_DIR-$configDir}"
+        export KAPOWARR_LOG_DIR="''${KAPOWARR_LOG_DIR-$configDir}"
+        export KAPOWARR_DOWNLOAD_DIR="''${KAPOWARR_DOWNLOAD_DIR-$configDir/temp_downloads}"
+        mkdir -p "$KAPOWARR_STATE_DIR" "$KAPOWARR_LOG_DIR"
+
+        if [ ! -f "$KAPOWARR_STATE_DIR/pwa_manifest.json" ]; then
+          cat "$OUTDIR/${python.sitePackages}/frontend/static/json/pwa_manifest.json" > "$KAPOWARR_STATE_DIR/pwa_manifest.json"
+        fi
+        '
+      ''
+    ];
+
+    postFixup = ''
+      # I prefer a clean name for the executable
+      mv $out/bin/Kapowarr.py $out/bin/${pname}
+
+      # Add missing resources that Kapowarr uses at runtime in sitePackages
+      cp -r ./frontend/{static,templates} "$out/${python.sitePackages}/frontend"
+    '';
+
+    meta = {
+      mainProgram = pname;
+      license = lib.licenses.gpl3Only;
+      homepage = "https://casvt.github.io/Kapowarr";
+      description = ''
+        Kapowarr is a software to build and manage a comic book library,
+        fitting in the *arr suite of software
+      '';
+    };
+  }
diff --git a/packages/kapowarr/main/raise-errors.patch b/packages/kapowarr/main/raise-errors.patch
new file mode 100644
index 00000000..4c5e76e9
--- /dev/null
+++ b/packages/kapowarr/main/raise-errors.patch
@@ -0,0 +1,26 @@
+ Kapowarr.py | 9 +++++++--
+ 1 file changed, 7 insertions(+), 2 deletions(-)
+
+diff --git a/Kapowarr.py b/Kapowarr.py
+index 0e9da56..306976c 100644
+--- a/Kapowarr.py
++++ b/Kapowarr.py
+@@ -185,8 +185,13 @@ if __name__ == "__main__":
+                 db_folder=db_folder
+             )
+
+-        except ValueError:
+-            parser.error("The value for -d/--DatabaseFolder is not a folder")
++        except ValueError as e:
++            if e.args and e.args[0] == 'Database location is not a folder':
++                parser.error(
++                    "The value for -d/--DatabaseFolder is not a folder"
++                )
++            else:
++                raise e
+
+     else:
+         rc = Kapowarr()
+--
+2.48.1
+
diff --git a/packages/kapowarr/tenacity/default.nix b/packages/kapowarr/tenacity/default.nix
new file mode 100644
index 00000000..a3f0a2d3
--- /dev/null
+++ b/packages/kapowarr/tenacity/default.nix
@@ -0,0 +1,49 @@
+{
+  # nix build inputs
+  lib,
+  buildPythonPackage,
+  fetchPypi,
+  # deps
+  pbr,
+  pytest-asyncio,
+  pytestCheckHook,
+  pythonOlder,
+  setuptools-scm,
+  tornado,
+  typeguard,
+  ...
+}: let
+  pname = "tenacity";
+  version = "8.2.3";
+in
+  buildPythonPackage {
+    inherit pname version;
+    format = "pyproject";
+
+    disabled = pythonOlder "3.6";
+
+    src = fetchPypi {
+      inherit pname version;
+      hash = "sha256-U5jvDXjmP0AAfB+0wL/5bhkROU0vqNGU93YZwF/2zIo=";
+    };
+
+    nativeBuildInputs = [
+      pbr
+      setuptools-scm
+    ];
+
+    nativeCheckInputs = [
+      pytest-asyncio
+      pytestCheckHook
+      tornado
+      typeguard
+    ];
+
+    pythonImportsCheck = [pname];
+
+    meta = {
+      homepage = "https://github.com/jd/tenacity";
+      description = "Retrying library for Python";
+      license = lib.licenses.asl20;
+    };
+  }
diff --git a/packages/kapowarr/typing-extensions/default.nix b/packages/kapowarr/typing-extensions/default.nix
new file mode 100644
index 00000000..dbd15b3b
--- /dev/null
+++ b/packages/kapowarr/typing-extensions/default.nix
@@ -0,0 +1,23 @@
+{
+  # nix build inputs
+  buildPythonPackage,
+  fetchPypi,
+  # deps
+  flit-core,
+  ...
+}: let
+  pname = "typing_extensions";
+  version = "4.12.2";
+in
+  buildPythonPackage {
+    inherit pname version;
+    format = "pyproject";
+
+    build-system = [flit-core];
+    dependencies = [flit-core];
+
+    src = fetchPypi {
+      inherit pname version;
+      hash = "sha256-Gn6tVcflWd1N7ohW46iLQSJav+HOjfV7fBORX+Eh/7g=";
+    };
+  }