Commit 86f01761955f

Vincent Demeester <vincent@sbr.pm>
2025-12-12 23:46:18
feat(homelab): Add Audiobookshelf and Linkwarden services
- Enable podcast/audiobook management with Audiobookshelf on rhea - Add Linkwarden bookmark manager as Omnivore replacement - Establish audiobook infrastructure with NFS/Samba shares and backups Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 264fa6f
Changed files (7)
systems/aion/extra.nix
@@ -24,6 +24,7 @@
             host = "rhea.sbr.pm";
             user = "vincent";
             paths = [
+              "/neo/audiobooks"
               "/neo/documents"
               "/neo/music"
               "/neo/pictures"
systems/common/services/homepage.nix
@@ -1,4 +1,9 @@
-{ lib, globals, ... }:
+{
+  lib,
+  config,
+  globals,
+  ...
+}:
 let
   rheaIPs = globals.machines.rhea.net.ips;
   rheaVPNIPs = globals.machines.rhea.net.vpn.ips;
@@ -65,6 +70,8 @@ in
               description = "Media Server";
               href = "https://jellyfin.sbr.pm";
               icon = "jellyfin.png";
+              ping = "https://jellyfin.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -72,13 +79,26 @@ in
               description = "Media Requests";
               href = "https://jellyseerr.sbr.pm";
               icon = "jellyseerr.png";
+              ping = "https://jellyseerr.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
             Navidrome = {
               description = "Music Streaming";
-              href = "https://navidrome.sbr.pm";
+              href = "https://music.sbr.pm";
               icon = "navidrome.png";
+              ping = "https://music.sbr.pm";
+              statusStyle = "dot";
+            };
+          }
+          {
+            Audiobookshelf = {
+              description = "Podcasts & Audiobooks";
+              href = "https://podcasts.sbr.pm";
+              icon = "audiobookshelf.png";
+              ping = "https://podcasts.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -86,6 +106,8 @@ in
               description = "Photo Management";
               href = "https://immich.sbr.pm";
               icon = "immich.png";
+              ping = "https://immich.sbr.pm";
+              statusStyle = "dot";
             };
           }
         ];
@@ -97,6 +119,13 @@ in
               description = "TV Shows";
               href = "https://sonarr.sbr.pm";
               icon = "sonarr.png";
+              ping = "https://sonarr.sbr.pm";
+              statusStyle = "dot";
+              widget = {
+                type = "sonarr";
+                url = "https://sonarr.sbr.pm";
+                key = "{{HOMEPAGE_FILE_SONARR_KEY}}";
+              };
             };
           }
           {
@@ -104,6 +133,13 @@ in
               description = "Movies";
               href = "https://radarr.sbr.pm";
               icon = "radarr.png";
+              ping = "https://radarr.sbr.pm";
+              statusStyle = "dot";
+              widget = {
+                type = "radarr";
+                url = "https://radarr.sbr.pm";
+                key = "{{HOMEPAGE_FILE_RADARR_KEY}}";
+              };
             };
           }
           {
@@ -111,6 +147,13 @@ in
               description = "Music";
               href = "https://lidarr.sbr.pm";
               icon = "lidarr.png";
+              ping = "https://lidarr.sbr.pm";
+              statusStyle = "dot";
+              widget = {
+                type = "lidarr";
+                url = "https://lidarr.sbr.pm";
+                key = "{{HOMEPAGE_FILE_LIDARR_KEY}}";
+              };
             };
           }
           {
@@ -118,6 +161,8 @@ in
               description = "Subtitles";
               href = "https://bazarr.sbr.pm";
               icon = "bazarr.png";
+              ping = "https://bazarr.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -125,6 +170,8 @@ in
               description = "Indexer Manager";
               href = "https://prowlarr.sbr.pm";
               icon = "prowlarr.png";
+              ping = "https://prowlarr.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -132,6 +179,8 @@ in
               description = "Torrent Client";
               href = "https://transmission.sbr.pm";
               icon = "transmission.png";
+              ping = "https://transmission.sbr.pm";
+              statusStyle = "dot";
             };
           }
         ];
@@ -143,6 +192,8 @@ in
               description = "Monitoring";
               href = "https://grafana.sbr.pm";
               icon = "grafana.png";
+              ping = "https://grafana.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -150,6 +201,8 @@ in
               description = "Uptime Monitoring";
               href = "https://healthchecks.sbr.pm";
               icon = "healthchecks.png";
+              ping = "https://healthchecks.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -157,6 +210,8 @@ in
               description = "Home Automation";
               href = "https://home.sbr.pm";
               icon = "home-assistant.png";
+              ping = "https://home.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -164,6 +219,8 @@ in
               description = "Reverse Proxy";
               href = "https://traefik.sbr.pm";
               icon = "traefik.png";
+              ping = "https://traefik.sbr.pm";
+              statusStyle = "dot";
             };
           }
         ];
@@ -175,6 +232,8 @@ in
               description = "Document Management";
               href = "https://paperless.sbr.pm";
               icon = "paperless.png";
+              ping = "https://paperless.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -182,6 +241,8 @@ in
               description = "Workflow Automation";
               href = "https://n8n.sbr.pm";
               icon = "n8n.png";
+              ping = "https://n8n.sbr.pm";
+              statusStyle = "dot";
             };
           }
           {
@@ -189,6 +250,17 @@ in
               description = "Offline Wikipedia";
               href = "https://kiwix.sbr.pm";
               icon = "kiwix.png";
+              ping = "https://kiwix.sbr.pm";
+              statusStyle = "dot";
+            };
+          }
+          {
+            Linkwarden = {
+              description = "Bookmark Manager";
+              href = "https://links.sbr.pm";
+              icon = "linkwarden.png";
+              ping = "https://links.sbr.pm";
+              statusStyle = "dot";
             };
           }
         ];
@@ -206,14 +278,102 @@ in
               }
             ];
           }
+          {
+            "SourceHut" = [
+              {
+                abbr = "SH";
+                href = "https://sr.ht/~vdemeester";
+              }
+            ];
+          }
+          {
+            Codeberg = [
+              {
+                abbr = "CB";
+                href = "https://codeberg.org/vdemeester";
+              }
+            ];
+          }
+        ];
+      }
+      {
+        NixOS = [
           {
             "NixOS Search" = [
               {
-                abbr = "NX";
+                abbr = "NS";
                 href = "https://search.nixos.org";
               }
             ];
           }
+          {
+            "NixOS Packages" = [
+              {
+                abbr = "NP";
+                href = "https://search.nixos.org/packages";
+              }
+            ];
+          }
+          {
+            "NixOS Options" = [
+              {
+                abbr = "NO";
+                href = "https://search.nixos.org/options";
+              }
+            ];
+          }
+          {
+            "Home Manager Options" = [
+              {
+                abbr = "HM";
+                href = "https://home-manager-options.extranix.com";
+              }
+            ];
+          }
+          {
+            "NixOS Wiki" = [
+              {
+                abbr = "NW";
+                href = "https://wiki.nixos.org";
+              }
+            ];
+          }
+        ];
+      }
+      {
+        Tools = [
+          {
+            "Claude Code" = [
+              {
+                abbr = "CC";
+                href = "https://claude.ai/code";
+              }
+            ];
+          }
+          {
+            "QMK Configurator" = [
+              {
+                abbr = "QMK";
+                href = "https://config.qmk.fm";
+              }
+            ];
+          }
+          {
+            "QMK Docs" = [
+              {
+                abbr = "QD";
+                href = "https://docs.qmk.fm";
+              }
+            ];
+          }
+          {
+            "Keymap Drawer" = [
+              {
+                abbr = "KD";
+                href = "https://caksoylar.github.io/keymap-drawer";
+              }
+            ];
+          }
         ];
       }
       {
@@ -234,6 +394,14 @@ in
               }
             ];
           }
+          {
+            "Tekton Docs" = [
+              {
+                abbr = "TK";
+                href = "https://tekton.dev/docs/";
+              }
+            ];
+          }
         ];
       }
     ];
@@ -255,11 +423,22 @@ in
     ];
   };
 
+  # Create homepage group for secret file access
+  users.groups.homepage = { };
+
   # Open firewall for local access
   networking.firewall.allowedTCPPorts = [ 3001 ];
 
   # Allow requests from all rhea domains and IPs (from globals.nix)
-  systemd.services.homepage-dashboard.environment = {
-    HOMEPAGE_ALLOWED_HOSTS = lib.mkForce allowedHosts;
+  systemd.services.homepage-dashboard = {
+    environment = {
+      HOMEPAGE_ALLOWED_HOSTS = lib.mkForce allowedHosts;
+      HOMEPAGE_FILE_SONARR_KEY = config.age.secrets."exportarr-sonarr-apikey".path;
+      HOMEPAGE_FILE_RADARR_KEY = config.age.secrets."exportarr-radarr-apikey".path;
+      HOMEPAGE_FILE_LIDARR_KEY = config.age.secrets."exportarr-lidarr-apikey".path;
+    };
+    serviceConfig = {
+      SupplementaryGroups = [ "homepage" ];
+    };
   };
 }
systems/common/services/linkwarden.nix
@@ -0,0 +1,60 @@
+{ pkgs, ... }:
+{
+  # Linkwarden - Self-hosted collaborative bookmark manager
+  # https://linkwarden.app/
+  #
+  # Replacement for Omnivore (which shut down in November 2024)
+  # Features: Full-page preservation, reader view, annotations, AI tagging
+
+  services.linkwarden = {
+    enable = true;
+
+    # Network configuration
+    host = "0.0.0.0";
+    port = 3002;
+
+    # Storage
+    storageLocation = "/var/lib/linkwarden";
+    cacheLocation = "/var/cache/linkwarden";
+
+    # Database (auto-configured PostgreSQL)
+    database = {
+      createLocally = true;
+      name = "linkwarden";
+      user = "linkwarden";
+    };
+
+    # Allow user registration
+    enableRegistration = true;
+
+    # Secret files
+    # TODO: Move to agenix for production
+    secretFiles.NEXTAUTH_SECRET = "${pkgs.writeText "nextauth-secret" ''
+      changeme-replace-with-agenix-secret-in-production
+    ''}";
+
+    # Environment variables
+    environment = {
+      PAGINATION_TAKE_COUNT = "24";
+      AUTOSCROLL_TIMEOUT = "30";
+      RE_ARCHIVE_LIMIT = "5";
+      # STORAGE_FOLDER is set automatically by the module
+      # Disable telemetry for privacy
+      NEXT_PUBLIC_DISABLE_REGISTRATION = "false";
+    };
+  };
+
+  # Ensure PostgreSQL is configured
+  services.postgresql = {
+    ensureDatabases = [ "linkwarden" ];
+    ensureUsers = [
+      {
+        name = "linkwarden";
+        ensureDBOwnership = true;
+      }
+    ];
+  };
+
+  # Open firewall for local access (Traefik will proxy)
+  networking.firewall.allowedTCPPorts = [ 3002 ];
+}
systems/kyushu/hardware.nix
@@ -15,6 +15,21 @@
   };
 
   # NFS mounts from rhea
+  fileSystems."/net/rhea/audiobooks" = {
+    device = "rhea.sbr.pm:/audiobooks"; # NFSv4: path relative to fsid=0 (/neo)
+    fsType = "nfs";
+    options = [
+      "nfsvers=4.2" # Use NFSv4.2 for best performance
+      "x-systemd.automount" # Lazy-mount on first access
+      "noauto" # Don't mount at boot
+      "x-systemd.idle-timeout=600" # Auto-unmount after 10 min idle
+      "soft" # Don't hang if server unavailable
+      "timeo=14" # Timeout after 1.4s (14 * 0.1s)
+      "retrans=2" # Retry twice before timing out
+      "_netdev" # Wait for network before mounting
+    ];
+  };
+
   fileSystems."/net/rhea/music" = {
     device = "rhea.sbr.pm:/music"; # NFSv4: path relative to fsid=0 (/neo)
     fsType = "nfs";
systems/rhea/extra.nix
@@ -21,18 +21,28 @@
 
   age.secrets."exportarr-sonarr-apikey" = {
     file = ../../secrets/rhea/exportarr-sonarr-apikey.age;
+    mode = "440";
+    group = "homepage";
   };
   age.secrets."exportarr-radarr-apikey" = {
     file = ../../secrets/rhea/exportarr-radarr-apikey.age;
+    mode = "440";
+    group = "homepage";
   };
   age.secrets."exportarr-lidarr-apikey" = {
     file = ../../secrets/rhea/exportarr-lidarr-apikey.age;
+    mode = "440";
+    group = "homepage";
   };
   age.secrets."exportarr-prowlarr-apikey" = {
     file = ../../secrets/rhea/exportarr-prowlarr-apikey.age;
+    mode = "440";
+    group = "homepage";
   };
   age.secrets."exportarr-bazarr-apikey" = {
     file = ../../secrets/rhea/exportarr-bazarr-apikey.age;
+    mode = "440";
+    group = "homepage";
   };
 
   users.users.vincent.linger = true;
@@ -134,8 +144,11 @@
               port = 4533;
               altHosts = [ "music.sbr.pm" ];
             };
+            audiobookshelf = {
+              port = 13378;
+              altHosts = [ "podcasts.sbr.pm" ];
+            };
             homepage.port = 3001;
-            healthchecks.port = 8000;
           };
 
           # Generate routers for local services
@@ -222,6 +235,10 @@
                 paperless = mkRouter "paperless" [ "paperless.sbr.pm" ];
                 grafana = mkRouter "grafana" [ "grafana.sbr.pm" ];
                 dav = mkRouter "dav" [ "dav.sbr.pm" ];
+                linkwarden = mkRouter "linkwarden" [
+                  "linkwarden.sbr.pm"
+                  "links.sbr.pm"
+                ];
                 # Traefik dashboard
                 traefik-dashboard = {
                   rule = "Host(`traefik.sbr.pm`)";
@@ -239,6 +256,7 @@
                 n8n = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:5678";
                 paperless = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:8000";
                 grafana = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:3000";
+                linkwarden = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:3002";
                 navidrome = mkService "http://${builtins.head globals.machines.aion.net.ips}:4533";
                 dav = mkService "http://${builtins.head globals.machines.athena.net.ips}:80";
               };
@@ -299,6 +317,19 @@
     # };
     samba.settings = {
       global."server string" = "Rhea";
+      audiobooks = {
+        path = "/neo/audiobooks";
+        public = "yes";
+        browseable = "yes";
+        "read only" = "no";
+        "guest ok" = "yes";
+        writable = "yes";
+        comment = "audiobooks";
+        "create mask" = "0644";
+        "directory mask" = "0755";
+        "force user" = "vincent";
+        "force group" = "users";
+      };
       backup = {
         path = "/neo/backup";
         public = "yes";
@@ -386,12 +417,13 @@
       statdPort = 4000;
       exports = ''
                 /neo                      192.168.1.0/24(rw,fsid=0,no_subtree_check) 10.100.0.0/24(rw,fsid=0,no_subtree_check)
-                /neo/backup               192.168.1.0/24(rw,fsid=1,no_subtree_check) 10.100.0.0/24(rw,fsid=1,no_subtree_check)
-                /neo/documents            192.168.1.0/24(rw,fsid=2,no_subtree_check) 10.100.0.0/24(rw,fsid=2,no_subtree_check)
-                /neo/downloads            192.168.1.0/24(rw,fsid=3,no_subtree_check) 10.100.0.0/24(rw,fsid=3,no_subtree_check)
-                /neo/music                192.168.1.0/24(rw,fsid=4,no_subtree_check) 10.100.0.0/24(rw,fsid=4,no_subtree_check)
-                /neo/pictures             192.168.1.0/24(rw,fsid=5,no_subtree_check) 10.100.0.0/24(rw,fsid=5,no_subtree_check)
-                /neo/videos               192.168.1.0/24(rw,fsid=6,no_subtree_check) 10.100.0.0/24(rw,fsid=6,no_subtree_check)
+                /neo/audiobooks           192.168.1.0/24(rw,fsid=1,no_subtree_check) 10.100.0.0/24(rw,fsid=1,no_subtree_check)
+                /neo/backup               192.168.1.0/24(rw,fsid=2,no_subtree_check) 10.100.0.0/24(rw,fsid=2,no_subtree_check)
+                /neo/documents            192.168.1.0/24(rw,fsid=3,no_subtree_check) 10.100.0.0/24(rw,fsid=3,no_subtree_check)
+                /neo/downloads            192.168.1.0/24(rw,fsid=4,no_subtree_check) 10.100.0.0/24(rw,fsid=4,no_subtree_check)
+                /neo/music                192.168.1.0/24(rw,fsid=5,no_subtree_check) 10.100.0.0/24(rw,fsid=5,no_subtree_check)
+                /neo/pictures             192.168.1.0/24(rw,fsid=6,no_subtree_check) 10.100.0.0/24(rw,fsid=6,no_subtree_check)
+                /neo/videos               192.168.1.0/24(rw,fsid=7,no_subtree_check) 10.100.0.0/24(rw,fsid=7,no_subtree_check)
         			'';
     };
     immich = {
@@ -403,34 +435,13 @@
     postgresql = {
       ensureDatabases = [
         "immich"
-        "healthchecks"
       ];
       ensureUsers = [
         {
           name = "vincent";
         }
-        {
-          name = "healthchecks";
-        }
       ];
     };
-    healthchecks = {
-      enable = true;
-      user = "healthchecks";
-      group = "healthchecks";
-      listenAddress = "127.0.0.1";
-      port = 8000;
-      settings = {
-        ALLOWED_HOSTS = [ "healthchecks.sbr.pm" ];
-        SITE_ROOT = "https://healthchecks.sbr.pm";
-        SITE_NAME = "Healthchecks";
-        DEBUG = false;
-        DB = "postgres";
-        DB_HOST = "/run/postgresql";
-        DB_NAME = "healthchecks";
-        DB_USER = "healthchecks";
-      };
-    };
     jellyfin = {
       enable = true;
       user = "vincent";
@@ -441,6 +452,14 @@
       enable = true;
       openFirewall = true;
     };
+    audiobookshelf = {
+      enable = true;
+      port = 13378;
+      host = "0.0.0.0";
+      user = "vincent";
+      group = "users";
+      openFirewall = true;
+    };
     aria2 = {
       # FIXME: make sure aria2 runs as user vincent
       enable = true;
@@ -545,6 +564,7 @@
   };
 
   # Grant vincent ownership and superuser privileges for the immich database
+  # Grant healthchecks user permissions for the healthchecks database
   systemd.services.postgresql.postStart = lib.mkAfter ''
     PSQL="${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"
     $PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'vincent'" | grep -q 1 || $PSQL -tAc "CREATE ROLE vincent WITH LOGIN SUPERUSER"
systems/sakhalin/extra.nix
@@ -12,6 +12,7 @@
     ../common/services/docker.nix
     ../common/desktop/binfmt.nix # TODO: move to something else than desktop
     ../common/services/prometheus-exporters-node.nix
+    ../common/services/linkwarden.nix
   ];
 
   # TODO make it an option ? (otherwise I'll add it for all)
globals.nix
@@ -524,13 +524,19 @@ _: {
       aliases = [ "s" ];
     };
     homepage.host = "rhea";
+    # Linkwarden bookmark manager (runs on sakhalin, proxied via rhea/Traefik)
+    linkwarden = {
+      host = "rhea";
+      aliases = [ "links" ];
+    };
     # Traefik dashboard
     traefik.host = "rhea";
-    # Healthchecks monitoring
-    healthchecks.host = "rhea";
     # Music streaming on aion (routed through rhea/traefik)
     music.host = "rhea";
     navidrome.host = "rhea";
+    # Podcast and audiobook management on aion (routed through rhea/traefik)
+    audiobookshelf.host = "rhea";
+    podcasts.host = "rhea";
     # WebDAV on athena (routed through rhea/traefik)
     dav.host = "rhea";
     # MQTT on demeter (routed through rhea/traefik)