Commit ea51ff3a5e7e

Vincent Demeester <vincent@sbr.pm>
2025-12-16 15:36:26
feat(jellyfin): Add automated collection management from external lists
- Automatically curate collections from IMDb, Letterboxd, and other sources - Eliminate manual maintenance with scheduled daily updates - Enable dynamic media discovery through popular curated lists Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent cc96b16
modules/jellyfin-auto-collections.nix
@@ -0,0 +1,234 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+
+with lib;
+
+let
+  cfg = config.services.jellyfin-auto-collections;
+
+  # Generate complete YAML config
+  # We'll use a template and substitute secrets at runtime
+  configYaml = lib.generators.toYAML { } (
+    {
+      jellyfin = {
+        server_url = cfg.jellyfinUrl;
+        api_key = "JELLYFIN_API_KEY_PLACEHOLDER";
+        user_id = cfg.userId;
+      };
+    }
+    // lib.optionalAttrs (cfg.jellyseerr.enable && cfg.jellyseerr.passwordFile != null) {
+      jellyseerr = {
+        server_url = cfg.jellyseerr.serverUrl;
+        email = cfg.jellyseerr.email;
+        password = "JELLYSEERR_PASSWORD_PLACEHOLDER";
+        user_type = cfg.jellyseerr.userType;
+      };
+    }
+    // cfg.settings
+  );
+
+  # Script to generate config file and run the application
+  startScript = pkgs.writeShellScript "jellyfin-auto-collections-start" ''
+        # Load API key from file if specified
+        ${lib.optionalString (cfg.apiKeyFile != null) ''
+          JELLYFIN_API_KEY=$(cat ${cfg.apiKeyFile})
+        ''}
+
+        # Load Jellyseerr password from file if specified
+        ${lib.optionalString (cfg.jellyseerr.enable && cfg.jellyseerr.passwordFile != null) ''
+          JELLYSEERR_PASSWORD=$(cat ${cfg.jellyseerr.passwordFile})
+        ''}
+
+        # Generate config.yml by substituting the placeholders
+        cat > ${cfg.dataDir}/config.yml << 'EOF'
+    ${configYaml}
+    EOF
+
+        # Replace the placeholders with actual secrets
+        sed -i "s/JELLYFIN_API_KEY_PLACEHOLDER/$JELLYFIN_API_KEY/g" ${cfg.dataDir}/config.yml
+        ${lib.optionalString (cfg.jellyseerr.enable && cfg.jellyseerr.passwordFile != null) ''
+          sed -i "s/JELLYSEERR_PASSWORD_PLACEHOLDER/$JELLYSEERR_PASSWORD/g" ${cfg.dataDir}/config.yml
+        ''}
+
+        chmod 600 ${cfg.dataDir}/config.yml
+
+        # Run the main script with config file
+        exec ${cfg.package}/bin/jellyfin-auto-collections --config ${cfg.dataDir}/config.yml
+  '';
+in
+{
+  options.services.jellyfin-auto-collections = {
+    enable = mkEnableOption "Jellyfin Auto Collections service";
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.jellyfin-auto-collections;
+      defaultText = literalExpression "pkgs.jellyfin-auto-collections";
+      description = "The jellyfin-auto-collections package to use.";
+    };
+
+    interval = mkOption {
+      type = types.str;
+      default = "daily";
+      description = ''
+        How often to run the collection update.
+        Uses systemd.time format (e.g., "hourly", "daily", "weekly", "*:0/30" for every 30 minutes).
+      '';
+    };
+
+    jellyfinUrl = mkOption {
+      type = types.str;
+      example = "http://localhost:8096";
+      description = "URL of the Jellyfin server";
+    };
+
+    apiKeyFile = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      description = ''
+        Path to a file containing the Jellyfin API key.
+        The file should contain only the API key.
+      '';
+    };
+
+    userId = mkOption {
+      type = types.str;
+      description = "Jellyfin user ID";
+    };
+
+    jellyseerr = {
+      enable = mkEnableOption "Jellyseerr integration";
+
+      serverUrl = mkOption {
+        type = types.str;
+        default = "http://localhost:5055";
+        description = "URL of the Jellyseerr server";
+      };
+
+      email = mkOption {
+        type = types.str;
+        description = "Jellyseerr user email";
+      };
+
+      passwordFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        description = ''
+          Path to a file containing the Jellyseerr password.
+          The file should contain only the password.
+        '';
+      };
+
+      userType = mkOption {
+        type = types.str;
+        default = "local";
+        description = "Jellyseerr user type (local or plex)";
+      };
+    };
+
+    settings = mkOption {
+      type = types.attrs;
+      default = { };
+      description = ''
+        Configuration for Jellyfin Auto Collections.
+        This will be converted to a config file.
+      '';
+      example = literalExpression ''
+        {
+          jellyfin = {
+            url = "http://localhost:8096";
+            user_id = "your-user-id";
+          };
+          lists = [
+            {
+              type = "imdb";
+              url = "https://www.imdb.com/list/ls123456789/";
+              name = "IMDb Top 250";
+            }
+          ];
+        }
+      '';
+    };
+
+    dataDir = mkOption {
+      type = types.path;
+      default = "/var/lib/jellyfin-auto-collections";
+      description = "Directory to store jellyfin-auto-collections data and cache";
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = "jellyfin-auto-collections";
+      description = "User account under which jellyfin-auto-collections runs.";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "jellyfin-auto-collections";
+      description = "Group under which jellyfin-auto-collections runs.";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # Create the user and group
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      group = cfg.group;
+      home = cfg.dataDir;
+      createHome = true;
+    };
+
+    users.groups.${cfg.group} = { };
+
+    # Systemd service
+    systemd.services.jellyfin-auto-collections = {
+      description = "Jellyfin Auto Collections";
+      after = [ "network.target" ];
+
+      serviceConfig = {
+        Type = "oneshot";
+        User = cfg.user;
+        Group = cfg.group;
+        WorkingDirectory = cfg.dataDir;
+
+        ExecStart = "${startScript}";
+
+        # Security hardening
+        PrivateTmp = true;
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        ReadWritePaths = [ cfg.dataDir ];
+        NoNewPrivileges = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+        PrivateMounts = true;
+        LockPersonality = true;
+      };
+
+      environment = {
+        JELLYFIN_SERVER_URL = cfg.jellyfinUrl;
+        JELLYFIN_USER_ID = cfg.userId;
+        HOME = cfg.dataDir;
+      };
+    };
+
+    # Systemd timer
+    systemd.timers.jellyfin-auto-collections = {
+      description = "Timer for Jellyfin Auto Collections";
+      wantedBy = [ "timers.target" ];
+
+      timerConfig = {
+        OnCalendar = cfg.interval;
+        Persistent = true;
+        RandomizedDelaySec = "5m";
+      };
+    };
+  };
+}
pkgs/jellyfin-auto-collections/default.nix
@@ -0,0 +1,75 @@
+{
+  lib,
+  python3,
+  fetchFromGitHub,
+  makeWrapper,
+}:
+
+python3.pkgs.buildPythonApplication rec {
+  pname = "jellyfin-auto-collections";
+  version = "unstable-2024-01-01";
+
+  format = "other"; # No setup.py or pyproject.toml
+
+  src = fetchFromGitHub {
+    owner = "ghomasHudson";
+    repo = "Jellyfin-Auto-Collections";
+    rev = "master";
+    hash = "sha256-4dfpOgZ6mCbyzFKeP53ZQpfjK1dOp45rsclQzoQjzeQ=";
+  };
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  propagatedBuildInputs = with python3.pkgs; [
+    apscheduler
+    beautifulsoup4
+    certifi
+    charset-normalizer
+    loguru
+    pluginlib
+    pyaml-env
+    pytz
+    pyyaml
+    requests
+    requests-cache
+    setuptools
+    six
+    soupsieve
+    tzlocal
+    urllib3
+    url-normalize
+    numpy
+    packaging
+    pillow
+    pyparsing
+    python-dateutil
+    attrs
+    cattrs
+    platformdirs
+  ];
+
+  dontBuild = true;
+  dontCheck = true;
+
+  installPhase = ''
+    runHook preInstall
+
+    mkdir -p $out/bin $out/lib/jellyfin-auto-collections
+    cp -r * $out/lib/jellyfin-auto-collections/
+
+    # Create wrapper script with proper PYTHONPATH
+    makeWrapper ${python3}/bin/python $out/bin/jellyfin-auto-collections \
+      --add-flags "$out/lib/jellyfin-auto-collections/main.py" \
+      --prefix PYTHONPATH : "$PYTHONPATH:$out/lib/jellyfin-auto-collections"
+
+    runHook postInstall
+  '';
+
+  meta = with lib; {
+    description = "Automatically make jellyfin collections from IMDB, Letterboxd lists and more";
+    homepage = "https://github.com/ghomasHudson/Jellyfin-Auto-Collections";
+    license = licenses.mit;
+    maintainers = [ ];
+    mainProgram = "jellyfin-auto-collections";
+  };
+}
pkgs/default.nix
@@ -28,6 +28,7 @@ in
   toggle-color-scheme = pkgs.callPackage ./toggle-color-scheme { };
   homepage = pkgs.callPackage ./homepage { inherit globals; };
   audible-converter = pkgs.callPackage ./audible-converter { };
+  jellyfin-auto-collections = pkgs.callPackage ./jellyfin-auto-collections { };
 
   chmouzies-ai = pkgs.callPackage ./chmouzies/ai.nix { };
   chmouzies-git = pkgs.callPackage ./chmouzies/git.nix { };
secrets/rhea/jellyfin-auto-collections-api-key.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA AywJmaCJGUKHR0T9x+IXegpGHI1axa3UIcjHFobQdL6Q
+D/bTcVMZaAc5OQadPW/sZxsOXV+P/SApQ9cZCn6FAOw
+-> piv-p256 ViCCtQ AhPO81SYNmbozFavfDXmeCwu6bVOD4JfIFaInBjgrNs9
+U8rTIr2LIsZTqeObLOyL5KvxGOHfaTWRq0dXoxgAUqc
+-> ssh-ed25519 EboMJg Y/G2U9hW2ivhG6ke0n96iXfqhoLC1SyNU14yG7lzBAE
+78QB8erMohDu25KzKR6cjZRXz9gLjcttBLpD2ofyW1M
+--- JkUTgCdJ/ikyj4OxZnU+AOwd9yzSnTviwoQpWI7oO00
+���Xе���U�U��W}O^�a�O���:ȸ�ɧTށ�����r� -O�%�"k�Ƒ������
\ No newline at end of file
secrets/rhea/jellyfin-auto-collections-jellyseerr-password.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA AuNrSh0isDY8oA8bXmOte+CYoCyt09I+Rb9m6P8hqUSx
+2yTFbjZ8q0OVVvBrierbC1NaLXBAwlxtffU7iCURw1M
+-> piv-p256 ViCCtQ AousyeYnVbivF+2623GoI74yIEreUx6a0lV82g8XRvPk
+RHGkuxxayRIoYJPv8+TcqZ0HOTLdt2n8XyExOcIREic
+-> ssh-ed25519 EboMJg iLpG/uCXgrxThlzYh19SiwmkdeGMASD0fIQ/GaySJzk
+C+AiFk6YaL/HuUAti8aKeWzjUTihx3bwNamlaxIgxME
+--- sOryvjZJ0Tl9AyZQQMeda2E//f2LKuzgQb0HvrM1wjE
+��� �ט	X������s�|o�C_	����^�`h�i0İ
\ No newline at end of file
systems/rhea/extra.nix
@@ -52,6 +52,7 @@ in
     ../common/services/samba.nix
     ../common/services/homepage.nix
     ../../modules/audible-sync.nix
+    ../../modules/jellyfin-auto-collections.nix
   ];
 
   # Age secrets: gandi.env + generated exportarr secrets
@@ -62,6 +63,16 @@ in
       owner = "traefik";
       group = "traefik";
     };
+    "jellyfin-auto-collections-api-key" = {
+      file = ../../secrets/rhea/jellyfin-auto-collections-api-key.age;
+      mode = "400";
+      owner = "jellyfin-auto-collections";
+    };
+    "jellyfin-auto-collections-jellyseerr-password" = {
+      file = ../../secrets/rhea/jellyfin-auto-collections-jellyseerr-password.age;
+      mode = "400";
+      owner = "jellyfin-auto-collections";
+    };
   }
   // lib.mapAttrs' (
     name: _cfg:
@@ -429,6 +440,102 @@ in
         topic = "homelab";
       };
     };
+    jellyfin-auto-collections = {
+      enable = true;
+      jellyfinUrl = "http://localhost:8096";
+      userId = "400fef4e0ab2448cb8a2bc8ca2facc4f";
+      apiKeyFile = config.age.secrets."jellyfin-auto-collections-api-key".path;
+      interval = "daily"; # Run daily at midnight
+
+      jellyseerr = {
+        enable = false; # Enable when password secret is created
+        serverUrl = "http://localhost:5055";
+        email = "vincent@sbr.pm";
+        # Uncomment when jellyseerr password secret is created
+        # passwordFile = config.age.secrets."jellyfin-auto-collections-jellyseerr-password".path;
+        userType = "local";
+      };
+
+      settings = {
+        plugins = {
+          imdb_chart = {
+            enabled = true;
+            list_ids = [
+              "top"
+              "moviemeter"
+            ];
+            clear_collection = true;
+          };
+          imdb_list = {
+            enabled = true;
+            list_ids = [
+              "ls055592025" # IMDb Top 250
+            ];
+          };
+          jellyfin_api = {
+            enabled = true;
+            list_ids = [
+              # Marvel Cinematic Universe
+              {
+                studios = [
+                  "Marvel Studios"
+                  "Marvel Entertainment"
+                ];
+                list_name = "Marvel Cinematic Universe";
+                includeItemTypes = [ "Movie" ];
+              }
+              # Pixar Animation
+              {
+                studios = [ "Pixar" ];
+                list_name = "Pixar Collection";
+                includeItemTypes = [ "Movie" ];
+              }
+              # Studio Ghibli
+              {
+                studios = [ "Studio Ghibli" ];
+                list_name = "Studio Ghibli Collection";
+                includeItemTypes = [ "Movie" ];
+              }
+              # Sing Movies (Illumination)
+              {
+                searchTerm = "Sing";
+                studios = [ "Illumination Entertainment" ];
+                list_name = "Sing Movies";
+                includeItemTypes = [ "Movie" ];
+              }
+              # Christopher Nolan Films
+              {
+                person = [ "Christopher Nolan" ];
+                list_name = "Christopher Nolan Collection";
+                includeItemTypes = [ "Movie" ];
+              }
+              # Highly Rated Sci-Fi
+              {
+                genres = [ "Science Fiction" ];
+                minCriticRating = [ "8" ];
+                list_name = "Top Sci-Fi Movies";
+                includeItemTypes = [ "Movie" ];
+              }
+              # Recent Movies (2024-2025)
+              {
+                years = [
+                  2024
+                  2025
+                ];
+                list_name = "Recent Releases";
+                includeItemTypes = [ "Movie" ];
+              }
+              # Award Winners
+              {
+                tags = [ "Oscar Winner" ];
+                list_name = "Oscar Winners";
+                includeItemTypes = [ "Movie" ];
+              }
+            ];
+          };
+        };
+      };
+    };
     transmission = serviceDefaults // {
       enable = true;
       package = pkgs.transmission_4;
secrets.nix
@@ -99,5 +99,7 @@ in
   "secrets/rhea/exportarr-prowlarr-apikey.age".publicKeys = users ++ [ rhea ];
   "secrets/rhea/exportarr-readarr-apikey.age".publicKeys = users ++ [ rhea ];
   "secrets/rhea/exportarr-bazarr-apikey.age".publicKeys = users ++ [ rhea ];
+  "secrets/rhea/jellyfin-auto-collections-api-key.age".publicKeys = users ++ [ rhea ];
+  "secrets/rhea/jellyfin-auto-collections-jellyseerr-password.age".publicKeys = users ++ [ rhea ];
   "secrets/demeter/mosquitto-homeassistant-password.age".publicKeys = users ++ [ demeter ];
 }