From 69a63dc615dc0322e3785e53ea5bd5d248286dc5 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sun, 19 Jul 2026 21:27:50 +0200 Subject: [PATCH] Add *arr media stack + Gitea to jupiter - sabnzbd, prowlarr, sonarr, radarr, clonarr, seerr, cinephage, mediamanager services, wired into jupiter with LAN Caddy vhosts. - Gitea: migrated the old ZimaOS docker instance's data (sqlite db, 4 repos, no LFS objects) into the NixOS module's default stateDir layout. HTTP via Caddy; git SSH on its own built-in server at :2222 (not :222 - the unpriv gitea user can't bind <1024). - mediamanager-nix flake input for the mediamanager service. Co-Authored-By: Claude Sonnet 5 --- flake.lock | 122 +++++++++++++++++++++++++++++++- flake.nix | 6 +- hosts/jupiter/configuration.nix | 21 +++++- hosts/jupiter/secrets.nix | 1 + secrets/jupiter.yaml | 8 ++- services/cinephage.nix | 41 +++++++++++ services/clonarr.nix | 28 ++++++++ services/containers.nix | 14 +--- services/gitea.nix | 48 +++++++++++++ services/jellyfin.nix | 18 ++--- services/mediamanager.nix | 54 ++++++++++++++ services/prowlarr.nix | 30 ++++++++ services/radarr.nix | 18 +++++ services/sabnzbd.nix | 46 ++++++++++++ services/seerr.nix | 30 ++++++++ services/sonarr.nix | 18 +++++ 16 files changed, 476 insertions(+), 27 deletions(-) create mode 100644 services/cinephage.nix create mode 100644 services/clonarr.nix create mode 100644 services/gitea.nix create mode 100644 services/mediamanager.nix create mode 100644 services/prowlarr.nix create mode 100644 services/radarr.nix create mode 100644 services/sabnzbd.nix create mode 100644 services/seerr.nix create mode 100644 services/sonarr.nix diff --git a/flake.lock b/flake.lock index bbf67a2..0ba5a73 100644 --- a/flake.lock +++ b/flake.lock @@ -20,6 +20,46 @@ "type": "github" } }, + "media-manager": { + "flake": false, + "locked": { + "lastModified": 1767373218, + "narHash": "sha256-kMFxhRYhNTqmcB9uJV44FM9oAa4Wa+Vi4LQPnhEpUZ0=", + "owner": "maxdorninger", + "repo": "MediaManager", + "rev": "d420a2714c28f3d6a5651c437cfb8f2300463ed2", + "type": "github" + }, + "original": { + "owner": "maxdorninger", + "repo": "MediaManager", + "type": "github" + } + }, + "mediamanager-nix": { + "inputs": { + "media-manager": "media-manager", + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + }, + "locked": { + "lastModified": 1768002052, + "narHash": "sha256-s/BKe43bsICx6LiqRG2V5Ua2N7jQYLuI4+gLUY6zYjc=", + "owner": "strangeglyph", + "repo": "mediamanager-nix", + "rev": "867a9f1fd0f38eca33f72c37c30f82cd13d5c88c", + "type": "github" + }, + "original": { + "owner": "strangeglyph", + "repo": "mediamanager-nix", + "type": "github" + } + }, "nixos-images": { "inputs": { "nixos-stable": "nixos-stable", @@ -75,11 +115,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1783703440, - "narHash": "sha256-O3/YajjWo001VUIgD8BwaRdSNLUFe7nZ1qV5TwhRBcw=", + "lastModified": 1784280462, + "narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8f0500b9660505dc3cb647775fe9a978a74b5283", + "rev": "293d6abedf0478e681a4dfcfcb35b30fc796a32f", "type": "github" }, "original": { @@ -89,9 +129,60 @@ "type": "github" } }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "mediamanager-nix", + "nixpkgs" + ], + "pyproject-nix": [ + "mediamanager-nix", + "pyproject-nix" + ], + "uv2nix": [ + "mediamanager-nix", + "uv2nix" + ] + }, + "locked": { + "lastModified": 1763662255, + "narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "042904167604c681a090c07eb6967b4dd4dae88c", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "mediamanager-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1764134915, + "narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "2c8df1383b32e5443c921f61224b198a2282a657", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, "root": { "inputs": { "disko": "disko", + "mediamanager-nix": "mediamanager-nix", "nixos-images": "nixos-images", "nixpkgs": "nixpkgs", "sops-nix": "sops-nix" @@ -116,6 +207,31 @@ "repo": "sops-nix", "type": "github" } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "mediamanager-nix", + "nixpkgs" + ], + "pyproject-nix": [ + "mediamanager-nix", + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1767152253, + "narHash": "sha256-xxuRsew0pedwptVnhfru01xbe+dDhI+OY1kCFDMxBUs=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "7a3eb140416318349ec58d2d4e81afe071bc9f03", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 858f1f8..90163a2 100644 --- a/flake.nix +++ b/flake.nix @@ -15,9 +15,13 @@ url = "github:nix-community/nixos-images"; inputs.nixpkgs.follows = "nixpkgs"; }; + mediamanager-nix = { + url = "github:strangeglyph/mediamanager-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs, disko, sops-nix, nixos-images, ... }@inputs: + outputs = { self, nixpkgs, disko, sops-nix, nixos-images, mediamanager-nix, ... }@inputs: let system = "x86_64-linux"; in diff --git a/hosts/jupiter/configuration.nix b/hosts/jupiter/configuration.nix index 186c436..4613348 100644 --- a/hosts/jupiter/configuration.nix +++ b/hosts/jupiter/configuration.nix @@ -14,8 +14,19 @@ ../../services/caddy.nix ../../services/tailscale.nix ../../services/jellyfin.nix + ../../services/sabnzbd.nix + ../../services/prowlarr.nix + ../../services/sonarr.nix + ../../services/radarr.nix + ../../services/clonarr.nix + ../../services/seerr.nix + ../../services/gitea.nix ]; + # sabnzbd's unrar dependency is unfree; scope the allowance to just that + # package rather than blanket-allowing unfree across the host. + nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "unrar" ]; + # ---- Host identity ---- networking.hostName = "jupiter"; networking.networkmanager.enable = true; @@ -49,11 +60,17 @@ }; # ---- Caddy vhosts (LAN) ---- - # whoami on :80, audiobookshelf via a pihole local-DNS name -> jupiter IP. + # Reached via pihole local-DNS names -> jupiter IP. services.caddy.virtualHosts = { - "http://localhost".extraConfig = "reverse_proxy localhost:8080"; "http://audiobookshelf.jupiter.sol".extraConfig = "reverse_proxy localhost:8000"; "http://jellyfin.jupiter.sol".extraConfig = "reverse_proxy localhost:8096"; + "http://sabnzbd.jupiter.sol".extraConfig = "reverse_proxy localhost:8085"; + "http://prowlarr.jupiter.sol".extraConfig = "reverse_proxy localhost:9696"; + "http://sonarr.jupiter.sol".extraConfig = "reverse_proxy localhost:8989"; + "http://radarr.jupiter.sol".extraConfig = "reverse_proxy localhost:7878"; + "http://clonarr.jupiter.sol".extraConfig = "reverse_proxy localhost:6060"; + "http://seerr.jupiter.sol".extraConfig = "reverse_proxy localhost:5055"; + "http://gitea.jupiter.sol".extraConfig = "reverse_proxy localhost:3000"; }; system.stateVersion = "26.05"; diff --git a/hosts/jupiter/secrets.nix b/hosts/jupiter/secrets.nix index 6c93acf..b3758b0 100644 --- a/hosts/jupiter/secrets.nix +++ b/hosts/jupiter/secrets.nix @@ -25,4 +25,5 @@ # Headscale pre-auth key for tailscale auto-registration (see configuration.nix). sops.secrets.tailscale_authkey = { }; + } diff --git a/secrets/jupiter.yaml b/secrets/jupiter.yaml index 8adfab5..93b3371 100644 --- a/secrets/jupiter.yaml +++ b/secrets/jupiter.yaml @@ -1,6 +1,10 @@ samba_password: ENC[AES256_GCM,data:K3FtKC0CrOLMyfQokmxxlyUnDPo=,iv:9bTE/S/i05LJYldOHuBuz3+g8JdTuq0ysFFgXOmfti8=,tag:77NIxy7qpHRqCmbOR83AfA==,type:str] darman_password: ENC[AES256_GCM,data:DOHHlM4Qdw4WgkN+/M51n2LMjJqq5MS0FeGEH8Pz3297yUZ8jBDOKRS/Tek9stC05JKEQRtn/vVVbAY0nXs20MwrsDMo+IEFXx7Ms90vVyYIYe5O5/0aQkPq84vcwGN0RW1Rj5Y3s38vWA==,iv:DAqHbvOBq7FT7ALbmBXJ0HadEGEsL8V2e7R99H5ZH0s=,tag:9XpiRFIbaZjMn59uHCOA1g==,type:str] tailscale_authkey: ENC[AES256_GCM,data:GJjN9zdU3zNBxA8cEBdsmjDrj8G/m04r8JRCEDZkdd7jYplEHyLfhbMcIyZIBe3Sh4d3PM/PcKb191LZoMg/b1G4hiVwciaCoMY0O9kAhRg/aYHXluSIBQ==,iv:R3OsZ+3to0tBhF4IaaSHm0afkw5o0KfXkcTdT9HFucg=,tag:QQNXC9ya88lO087iGIc1sw==,type:str] +mediamanager_token_secret: ENC[AES256_GCM,data:g75vj1E6B029O076yV3DS/1z99Tq6wMhEVx+ULYDjHsplyA+vqVRvBlviC64V47IMqKd9k2eTGBJ98Ptv3UIjbr446xZinz0c7PZgHU0XOX4EUcB1ORloMFZIv1zUv8VRjUISUQnn/vRk60e7u8eXrOrgXjxfnoBKrdKZmqxhAM=,iv:2t0XBExC9RvbTomezka+99/LtJl64zNwneA7WWc4ju0=,tag:SVCYuw0n6JevnvoQOIkjPw==,type:str] +sabnzbd_api_key: ENC[AES256_GCM,data:6UW1u2Ikmnq34t4H4k/4C44SJeFHRlaPjWwUjEfH1GQ=,iv:sGsd8Sd2pfUhTUDg6PlRzfVYejRbF69jmDTIa2fvY4M=,tag:3fOLgU1K1gKHxQ3J1+3oRQ==,type:str] +prowlarr_api_key: ENC[AES256_GCM,data:ab1QACaagI8ACJXFUy8r9X9lgYwRo1byUmhBPSRWwKk=,iv:EcuF6EN/4mWxlXi6R1qDzv4rOw6AT+OGSNQaaBwjJHg=,tag:hixHrbQWU6QQZNMM5rNDsg==,type:str] +cinephage_better_auth_secret: ENC[AES256_GCM,data:S1ilcQeC2HmXe/4xdLi6wm5RNz954SL3qVur6JCn5ekBVCbXMd1DGCafjhU=,iv:9rS5gDuazMOAq/hWp0onvHZPzKJgQM3oWIrtplJN/9I=,tag:xNqf/unY2v/98p4v52vUqw==,type:str] sops: age: - enc: | @@ -21,7 +25,7 @@ sops: CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ== -----END AGE ENCRYPTED FILE----- recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt - lastmodified: "2026-07-12T22:19:04Z" - mac: ENC[AES256_GCM,data:iDcCp2dE657x9xDGM4Ki76gycaOM/7Z8v7qdl3Bcf0HcbH0oKWE2nShBO8/YK67p+Dkhghp/83G8vXCmEo0MA83C+gHha6JAyX2UJtvtVhBgF0ul8LMnXt4l+l7Ps8Bjne+LLtUK3U/2LGvADANPjNbpbCwe6x9OUPHmPXWYDxo=,iv:Cnrxb3xPCvq52DoktC1Osv1WbF/I7wfIYbAVRi2eNg4=,tag:Yodqbtl1+DnDvrtgT1lawQ==,type:str] + lastmodified: "2026-07-18T00:02:37Z" + mac: ENC[AES256_GCM,data:By7/CkQ8bftrGzXwaD9gl1G/iV5AuJEnxHUAK1wMivzUj4mjTvtd3m/RaDd9aFuplNREKBAvUuH4WJ4RrrK+NTNqEsy+VUxch4n/3QC0TSjiEersJR8GpSbfa+AlZ7xNxS11AvV0NGW0PZzxIToR75Yn+hW4SoqeoLy+Nr3b+3U=,iv:sBdbOZl7JbzcR7kq0cyMxv9RATIzs0SheoC32H38iWA=,tag:XeL54IV5xK8+Oa7JhWPTkw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.1 diff --git a/services/cinephage.nix b/services/cinephage.nix new file mode 100644 index 0000000..9ab9a4a --- /dev/null +++ b/services/cinephage.nix @@ -0,0 +1,41 @@ +{ config, ... }: + +# Cinephage — indexer search + streaming/library manager. Runs the official +# container image, not upstream's nix flake module: their flake's +# npmDepsHash is stale against their own package-lock.json (fixed-output +# hash mismatch), and past that, a transitive dep's postinstall hard-enforces +# pnpm (`only-allow pnpm`), which fails under nix's network-sandboxed npm +# build regardless. Docker is their actually-maintained deployment path. +# Paired sops secret in hosts/jupiter/secrets.nix — BETTER_AUTH_SECRET signs +# sessions/encrypts stored API keys; losing it invalidates all sessions and +# makes saved keys unrecoverable, so it must be static, not app-generated. +{ + virtualisation.oci-containers.containers.cinephage = { + image = "ghcr.io/moldytaint/cinephage:latest"; + autoStart = true; + # Host networking, not a published port: cinephage needs to reach + # Prowlarr/SABnzbd on jupiter's own localhost (they're native systemd + # services, not containers) — bridge-mode "localhost" would be the + # container's own netns, not the host's. + extraOptions = [ "--network=host" ]; + volumes = [ + "/mnt/data/AppData/cinephage:/config" + "/mnt/data/HighSeas:/media" + "/mnt/data/HighSeas/Downloads:/downloads" + ]; + environment = { + PUID = "1000"; + PGID = "100"; # darman:users — matches HighSeas' real on-disk ownership + TZ = "Europe/Berlin"; + ORIGIN = "http://cinephage.jupiter.sol"; + }; + environmentFiles = [ config.sops.templates."cinephage.env".path ]; + }; + + sops.templates."cinephage.env".content = + "BETTER_AUTH_SECRET=${config.sops.placeholder.cinephage_better_auth_secret}"; + + systemd.tmpfiles.rules = [ + "d /mnt/data/AppData/cinephage 0755 darman users -" + ]; +} diff --git a/services/clonarr.nix b/services/clonarr.nix new file mode 100644 index 0000000..8479a18 --- /dev/null +++ b/services/clonarr.nix @@ -0,0 +1,28 @@ +{ ... }: + +# Clonarr — visual TRaSH-Guides sync tool for Radarr/Sonarr (quality +# profiles, custom formats, scores). No nixpkgs package; runs the official +# container image (ghcr.io/prophetse7en/clonarr). +{ + virtualisation.oci-containers.containers.clonarr = { + image = "ghcr.io/prophetse7en/clonarr:latest"; + autoStart = true; + # Host networking: clonarr needs to reach Radarr/Sonarr/Prowlarr on + # jupiter's own localhost (native systemd services, not containers) — + # bridge-mode "localhost" would be the container's own netns, not the host's. + extraOptions = [ "--network=host" ]; + volumes = [ + "/mnt/data/AppData/clonarr:/config" + ]; + environment = { + TZ = "Europe/Berlin"; + PUID = "1000"; + PGID = "100"; # darman:users + PORT = "6060"; + }; + }; + + systemd.tmpfiles.rules = [ + "d /mnt/data/AppData/clonarr 0755 darman users -" + ]; +} diff --git a/services/containers.nix b/services/containers.nix index 9d5a0e6..b2f3837 100644 --- a/services/containers.nix +++ b/services/containers.nix @@ -1,7 +1,8 @@ { ... }: # Podman (Docker-compatible) + declarative OCI containers. -# Copy the whoami block per app; publish ports and mount /mnt/data volumes. +# Add containers under virtualisation.oci-containers.containers; publish +# ports and mount /mnt/data volumes as needed. { virtualisation.podman = { enable = true; @@ -9,14 +10,5 @@ defaultNetwork.settings.dns_enabled = true; }; - virtualisation.oci-containers = { - backend = "podman"; - containers = { - whoami = { - image = "traefik/whoami:latest"; - ports = [ "8080:80" ]; - autoStart = true; - }; - }; - }; + virtualisation.oci-containers.backend = "podman"; } diff --git a/services/gitea.nix b/services/gitea.nix new file mode 100644 index 0000000..466890f --- /dev/null +++ b/services/gitea.nix @@ -0,0 +1,48 @@ +{ ... }: + +# Gitea — self-hosted git. stateDir/repositories were migrated from the old +# ZimaOS docker instance (single user/org "darman", sqlite db, 4 repos, no +# LFS objects yet) into stateDir's default layout (data/gitea.db, +# data/{indexers,avatars,sessions,...}, repositories/), so no import step is +# needed on first boot — NixOS just needs to chown it to the gitea user +# (see below). +# +# HTTP is reverse-proxied through Caddy (see hosts/jupiter/configuration.nix +# for the vhost). SSH clone/push uses gitea's own built-in SSH server on +# :2222, kept separate from the host's OpenSSH on :22. NOT :222 (the old +# docker setup's external port) — gitea runs as the unpriv "gitea" user with +# an empty capability set, so it can't bind <1024: "listen tcp :222: bind: +# permission denied". +# +# After first deploy, fix ownership of the migrated data (it currently +# belongs to darman:users from the CIFS copy): +# chown -R gitea:gitea /mnt/data/AppData/gitea +{ + services.gitea = { + enable = true; + stateDir = "/mnt/data/AppData/gitea"; + lfs.enable = true; + + settings = { + repository = { + DEFAULT_BRANCH = "master"; + }; + server = { + DOMAIN = "git.mgaction.town"; + SSH_DOMAIN = "git.mgaction.town"; + ROOT_URL = "http://git.mgaction.town/"; + HTTP_PORT = 3000; + START_SSH_SERVER = true; + SSH_PORT = 2222; + SSH_LISTEN_PORT = 2222; + }; + service = { + DISABLE_REGISTRATION = true; + }; + }; + }; + + networking.firewall.allowedTCPPorts = [ 2222 ]; + + users.users.gitea.extraGroups = [ "users" ]; +} diff --git a/services/jellyfin.nix b/services/jellyfin.nix index a018bd0..9bc0c55 100644 --- a/services/jellyfin.nix +++ b/services/jellyfin.nix @@ -1,16 +1,18 @@ -{ ... }: +{ lib, ... }: -# Audiobookshelf audiobook/podcast server. -# Listens on all interfaces: :8000 stays closed on the LAN (no openFirewall), -# but reachable over the trusted tailscale0 interface and via localhost (caddy). -# Library/media paths are set in the web UI — point them at /mnt/data/... -# Runs as user `audiobookshelf`; added to `users` so it can read group-owned -# library dirs on the RAID. { services.jellyfin = { enable = true; dataDir = "/mnt/data/AppData/jellyfin"; - cacheDir = "${cfg.dataDir}/cache"; + cacheDir = "/mnt/data/AppData/jellyfin/cache"; }; users.users.jellyfin.extraGroups = [ "users" ]; + + # The upstream module hardcodes UMask=0077 — root cause of jellyfin writing + # trickplay thumbnails into stray new show folders it invented itself, + # owned jellyfin:jellyfin 700, invisible to every other service sharing + # the library (cinephage, mediamanager, ...). New files/dirs it creates + # from here on inherit group "users" (library roots are setgid, see the + # one-time chmod g+s done by hand) and stay group-writable. + systemd.services.jellyfin.serviceConfig.UMask = lib.mkForce "0002"; } diff --git a/services/mediamanager.nix b/services/mediamanager.nix new file mode 100644 index 0000000..91efe3c --- /dev/null +++ b/services/mediamanager.nix @@ -0,0 +1,54 @@ +{ config, ... }: + +# MediaManager — media request/library manager. Module comes from the +# community flake input `mediamanager-nix` (wired into jupiter's module list +# in flake.nix, NOT nixpkgs). Paired sops secret declared in +# hosts/jupiter/secrets.nix — without it the module mints+discards a random +# auth token_secret on every service restart, logging everyone out. +# Port 8010: 8000 is taken by audiobookshelf on this host. +{ + services.media-manager = { + enable = true; + dataDir = "/mnt/data/AppData/mediamanager"; + host = "0.0.0.0"; + port = 8010; + postgres.enable = true; + environmentFile = config.sops.templates."mediamanager.env".path; + settings = { + misc = { + frontend_url = "http://mediamanager.jupiter.sol"; + # Point straight at the existing library instead of the empty + # dirs under dataDir — group "users" needs write access (see + # the chmod note below); files stay darman-owned. + movie_directory = "/mnt/data/HighSeas/Movies"; + tv_directory = "/mnt/data/HighSeas/Shows"; + image_directory = "/mnt/data/HighSeas/images"; + torrent_directory = "/mnt/data/HighSeas/Downloads"; + }; + auth.admin_emails = [ "mail@erik-s.dev" ]; + # API keys are secret -> env vars via the sops template below, not here + # (settings.* is rendered to a world-readable file in /nix/store). + torrents.sabnzbd = { + enabled = true; + host = "http://localhost"; + port = 8085; + }; + indexers.prowlarr = { + enabled = true; + url = "http://localhost:9696"; + }; + }; + }; + + sops.templates."mediamanager.env".content = '' + MEDIAMANAGER_AUTH__TOKEN_SECRET=${config.sops.placeholder.mediamanager_token_secret} + MEDIAMANAGER_TORRENTS__SABNZBD__API_KEY=${config.sops.placeholder.sabnzbd_api_key} + MEDIAMANAGER_INDEXERS__PROWLARR__API_KEY=${config.sops.placeholder.prowlarr_api_key} + ''; + + # HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 on disk — + # group has no write bit. media-manager is in "users" (below); the dirs + # themselves were chmod g+w by hand once (not declarative — see CLAUDE.md + # gotchas), since this is pre-existing data, not something tmpfiles owns. + users.users.media-manager.extraGroups = [ "users" ]; +} diff --git a/services/prowlarr.nix b/services/prowlarr.nix new file mode 100644 index 0000000..ae43e79 --- /dev/null +++ b/services/prowlarr.nix @@ -0,0 +1,30 @@ +{ ... }: + +# Prowlarr — indexer manager (usenet + torrent), feeds SABnzbd/MediaManager. +# services.prowlarr.dataDir is left at its module default (/var/lib/prowlarr) +# on purpose: passing a *custom* dataDir makes the upstream module bind-mount +# it and force-reset the outer dir to 0700 root:root via a tmpfiles rule on +# every boot (nixos/modules/services/misc/servarr/prowlarr.nix) — that stomps +# DynamicUser's access to pre-existing content and causes intermittent +# "unable to open database file". +# +# Instead we bind-mount the real (migrated-from-ZimaOS) config dir straight +# onto the module's own default path, so prowlarr never sees a "custom" +# dataDir and none of that logic triggers. DynamicUser+StateDirectory then +# recursively chowns the pre-existing content to its assigned uid on first +# activation, same as it does for a fresh install — no manual chown needed. +# +# Mount onto /var/lib/private/prowlarr, NOT the public /var/lib/prowlarr: +# DynamicUser+StateDirectory keeps real data at .../private/ and makes +# the public path a symlink to it; binding onto the public path turns it into +# a mountpoint systemd then can't rename during its migrate-on-start dance +# ("Device or resource busy", exit 238/STATE_DIRECTORY). +{ + services.prowlarr.enable = true; + + fileSystems."/var/lib/private/prowlarr" = { + device = "/mnt/data/AppData/prowlarr/config"; + fsType = "none"; + options = [ "bind" ]; + }; +} diff --git a/services/radarr.nix b/services/radarr.nix new file mode 100644 index 0000000..3bd9e7f --- /dev/null +++ b/services/radarr.nix @@ -0,0 +1,18 @@ +{ ... }: + +# Radarr — movie library manager, feeds off SABnzbd/Prowlarr. dataDir points +# at the config migrated from the old ZimaOS docker stack (indexers/download +# client/history already set up). Unlike prowlarr, this module uses a static +# `radarr` user (no DynamicUser) and only auto-chowns dataDir when it's the +# module's own default path — since we point at a pre-existing migrated dir, +# chown it by hand once after first deploy: +# chown -R radarr:radarr /mnt/data/AppData/radarr/config +{ + services.radarr = { + enable = true; + dataDir = "/mnt/data/AppData/radarr/config"; + }; + + # Write access to the shared library/downloads dirs (owned darman:users). + users.users.radarr.extraGroups = [ "users" ]; +} diff --git a/services/sabnzbd.nix b/services/sabnzbd.nix new file mode 100644 index 0000000..43d1196 --- /dev/null +++ b/services/sabnzbd.nix @@ -0,0 +1,46 @@ +{ ... }: + +# SABnzbd — usenet downloader. Reuses the config migrated from the old +# ZimaOS docker stack (servers/API key/history already set up) by pointing +# straight at the real ini instead of generating a fresh NixOS-managed one. +# Runs as the module's default dedicated `sabnzbd` system user — after first +# deploy, chown the migrated config dir to it (see README/CLAUDE notes): +# chown -R sabnzbd:sabnzbd /mnt/data/AppData/sabnzbd/config +{ + services.sabnzbd = { + enable = true; + configFile = "/mnt/data/AppData/sabnzbd/config/sabnzbd.ini"; + allowConfigWrite = true; # real pre-existing state — let sabnzbd keep saving it + }; + + # Write access to the shared downloads dir (owned darman:users on disk). + users.users.sabnzbd.extraGroups = [ "users" ]; + + # SABnzbd hardcodes completed job folders to 0700 on every single job — + # independent of the ini's `umask` setting (that only covers files + # created during unpack, not the enclosing per-job dir). setgid on + # Downloads keeps the *group* as "users", but group perm bits still come + # back zeroed, locking out cinephage/mediamanager. Sweep it clean instead + # of fighting SABnzbd's own behavior. + systemd.services.fix-downloads-perms = { + description = "Fix group perms SABnzbd resets on completed downloads"; + serviceConfig.Type = "oneshot"; + script = '' + find /mnt/data/HighSeas/Downloads \ + ! -group users -exec chgrp users {} + 2>/dev/null || true + find /mnt/data/HighSeas/Downloads -type d ! -perm -g+rwx \ + -exec chmod g+rwx {} + 2>/dev/null || true + find /mnt/data/HighSeas/Downloads -type f ! -perm -g+rw \ + -exec chmod g+rw {} + 2>/dev/null || true + ''; + }; + + systemd.timers.fix-downloads-perms = { + description = "Periodically fix group perms under HighSeas/Downloads"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "1m"; + OnUnitActiveSec = "2m"; + }; + }; +} diff --git a/services/seerr.nix b/services/seerr.nix new file mode 100644 index 0000000..6871769 --- /dev/null +++ b/services/seerr.nix @@ -0,0 +1,30 @@ +{ ... }: + +# Seerr (formerly Jellyseerr) — request manager for Jellyfin, talks to +# Sonarr/Radarr to fulfill requests. Fresh install, no migrated data. +# +# configDir is left at the module default (/var/lib/seerr) on purpose: +# systemd's StateDirectory= auto-chown for DynamicUser only applies to that +# exact default path, not to an arbitrary custom one (same class of issue as +# prowlarr.nix, worked around the same way) — bind-mount AppData onto the +# default path instead of overriding configDir, so it stays on the RAID array +# and survives an OS-disk reinstall like every other service's data. +# +# Mount onto /var/lib/private/seerr, NOT the public /var/lib/seerr: +# DynamicUser+StateDirectory keeps real data at .../private/ and makes +# the public path a symlink to it; binding onto the public path turns it into +# a mountpoint systemd then can't rename during its migrate-on-start dance +# ("Device or resource busy", exit 238/STATE_DIRECTORY). +{ + services.seerr.enable = true; + + fileSystems."/var/lib/private/seerr" = { + device = "/mnt/data/AppData/seerr"; + fsType = "none"; + options = [ "bind" ]; + }; + + systemd.tmpfiles.rules = [ + "d /mnt/data/AppData/seerr 0755 darman users -" + ]; +} diff --git a/services/sonarr.nix b/services/sonarr.nix new file mode 100644 index 0000000..01ac41a --- /dev/null +++ b/services/sonarr.nix @@ -0,0 +1,18 @@ +{ ... }: + +# Sonarr — TV library manager, feeds off SABnzbd/Prowlarr. dataDir points at +# the config migrated from the old ZimaOS docker stack (indexers/download +# client/history already set up). Unlike prowlarr, this module uses a static +# `sonarr` user (no DynamicUser) and only auto-chowns dataDir when it's the +# module's own default path — since we point at a pre-existing migrated dir, +# chown it by hand once after first deploy: +# chown -R sonarr:sonarr /mnt/data/AppData/sonarr/config +{ + services.sonarr = { + enable = true; + dataDir = "/mnt/data/AppData/sonarr/config"; + }; + + # Write access to the shared library/downloads dirs (owned darman:users). + users.users.sonarr.extraGroups = [ "users" ]; +}