Commit 2019fb965431

Vincent Demeester <vincent@sbr.pm>
2026-01-07 20:27:33
feat(jellyfin): Add playlist support and interactive management tool
Major updates to jellyfin-favorites-sync and new jellyfin-manage-playlist tool: jellyfin-favorites-sync updates: - Add support for syncing playlists in addition to favorites - Add --playlist-id and --playlist-name options - Fix rsync source/destination order (was reversed) - Add SSH key authentication support via sshKeyFile option - Add dryRun option for safe testing - Update default destination from /data/favorites to /data/videos - Fix SSH known_hosts warnings with UserKnownHostsFile=/dev/null - Create dedicated service user with home directory New jellyfin-manage-playlist tool: - Interactive fzf-based tool for adding movies/series to playlists - Support for --item-type flag (movie, series, or both) - Mark items already in playlist with ★ and sort to top - Ctrl-S keybinding to select all starred items - Smart duplicate prevention - Environment variable support (JELLYFIN_URL, JELLYFIN_API_KEY, JELLYFIN_USER_ID) - Dry-run mode for safe testing Shared library updates (tools/arr/lib.py): - Add get_playlist_items_full() for querying playlist items with metadata - Add get_movies(), get_series(), get_items_by_type() methods - Add add_to_playlist() with proper 204 No Content handling - Update select_with_fzf() to support enable_star_select parameter Deployment: - Configured on rhea with "Keep" playlist - Syncs to aix.sbr.pm:/data/videos - Running in dry-run mode initially for safety - Daily schedule with randomized 5m delay Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 627507c
Changed files (11)
modules/jellyfin-favorites-sync/default.nix
@@ -93,18 +93,27 @@ in
 
       root = mkOption {
         type = types.str;
-        default = "/data/favorites";
+        default = "/data/videos";
         description = "Destination path on target host";
       };
     };
 
+    sshKeyFile = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      description = "Path to SSH private key file for rsync authentication (managed by agenix)";
+      example = "/run/agenix/jellyfin-favorites-sync-ssh-key";
+    };
+
     sshArgs = mkOption {
       type = types.listOf types.str;
-      default = [ "-o StrictHostKeyChecking=accept-new" ];
+      default = [
+        "-o StrictHostKeyChecking=no"
+        "-o UserKnownHostsFile=/dev/null"
+      ];
       description = "Additional SSH arguments";
       example = [
         "-p 2222"
-        "-i /home/jellyfin-favorites-sync/.ssh/id_ed25519"
       ];
     };
 
@@ -126,6 +135,12 @@ in
       description = "Randomized delay before starting the job (systemd RandomizedDelaySec)";
       example = "1h";
     };
+
+    dryRun = mkOption {
+      type = types.bool;
+      default = false;
+      description = "Enable dry-run mode (show what would be synced without making changes)";
+    };
   };
 
   config = mkIf cfg.enable {
@@ -134,6 +149,8 @@ in
       isSystemUser = true;
       group = cfg.group;
       description = "Jellyfin favorites sync service user";
+      home = "/var/lib/${cfg.user}";
+      createHome = true;
     };
 
     users.groups.${cfg.group} = { };
@@ -149,31 +166,36 @@ in
         User = cfg.user;
         Group = cfg.group;
 
-        ExecStart = pkgs.writeShellScript "jellyfin-favorites-sync-start" ''
-          set -euo pipefail
+        ExecStart =
+          let
+            # Build SSH args with optional key file
+            sshArgsWithKey = cfg.sshArgs ++ (optionals (cfg.sshKeyFile != null) [ "-i ${cfg.sshKeyFile}" ]);
+          in
+          pkgs.writeShellScript "jellyfin-favorites-sync-start" ''
+            set -euo pipefail
 
-          # Read API key from file
-          API_KEY=$(cat ${cfg.apiKeyFile})
+            # Read API key from file
+            API_KEY=$(cat ${cfg.apiKeyFile})
 
-          # Execute sync
-          ${cfg.package}/bin/jellyfin-favorites-sync \
-            --jellyfin-url "${cfg.jellyfinUrl}" \
-            --api-key "$API_KEY" \
-            --user-id "${cfg.userId}" \
-            ${optionalString (cfg.playlistId != null) "--playlist-id \"${cfg.playlistId}\""} \
-            ${optionalString (cfg.playlistName != null) "--playlist-name \"${cfg.playlistName}\""} \
-            --source-root "${cfg.sourceRoot}" \
-            --dest-host "${cfg.destination.host}" \
-            --dest-user "${cfg.destination.user}" \
-            --dest-root "${cfg.destination.root}" \
-            ${concatMapStringsSep " " (arg: "--ssh-arg '${arg}'") cfg.sshArgs} \
-            --verbose
-        '';
+            # Execute sync
+            ${cfg.package}/bin/jellyfin-favorites-sync \
+              --jellyfin-url "${cfg.jellyfinUrl}" \
+              --api-key "$API_KEY" \
+              --user-id "${cfg.userId}" \
+              ${optionalString (cfg.playlistId != null) "--playlist-id \"${cfg.playlistId}\""} \
+              ${optionalString (cfg.playlistName != null) "--playlist-name \"${cfg.playlistName}\""} \
+              --source-root "${cfg.sourceRoot}" \
+              --dest-host "${cfg.destination.host}" \
+              --dest-user "${cfg.destination.user}" \
+              --dest-root "${cfg.destination.root}" \
+              ${concatMapStringsSep " " (arg: "--ssh-arg '${arg}'") sshArgsWithKey} \
+              ${optionalString cfg.dryRun "--dry-run"} \
+              --verbose
+          '';
 
         # Security hardening
         PrivateTmp = true;
         ProtectSystem = "strict";
-        ProtectHome = true;
         NoNewPrivileges = true;
         ReadWritePaths = [ "/tmp" ]; # For rsync file lists
 
pkgs/default.nix
@@ -30,6 +30,7 @@ in
   audible-converter = pkgs.callPackage ./audible-converter { };
   jellyfin-auto-collections = pkgs.callPackage ./jellyfin-auto-collections { };
   jellyfin-favorites-sync = pkgs.callPackage ../tools/jellyfin-favorites-sync { };
+  jellyfin-manage-playlist = pkgs.callPackage ../tools/jellyfin-manage-playlist { };
   music-playlist-dl = pkgs.callPackage ../tools/music-playlist-dl { };
   nix-flake-update = pkgs.callPackage ../tools/nix-flake-update { };
   beets-lidarr-fields = pkgs.python3Packages.callPackage ./beets-lidarr-fields { };
secrets/rhea/jellyfin-favorites-sync-ssh-key.age
Binary file
systems/rhea/extra.nix
@@ -104,7 +104,12 @@ in
     "jellyfin-favorites-sync-api-key" = {
       file = ../../secrets/rhea/jellyfin-favorites-sync-api-key.age;
       mode = "400";
-      owner = "root";
+      owner = "jellyfin-favorites-sync";
+    };
+    "jellyfin-favorites-sync-ssh-key" = {
+      file = ../../secrets/rhea/jellyfin-favorites-sync-ssh-key.age;
+      mode = "400";
+      owner = "jellyfin-favorites-sync";
     };
     "restic-aix-password" = {
       file = ../../secrets/rhea/restic-aix-password.age;
@@ -622,12 +627,19 @@ in
       destination = {
         host = "aix.sbr.pm";
         user = "vincent";
-        root = "/data/favorites";
+        root = "/data/videos";
       };
 
+      # SSH key for authentication
+      sshKeyFile = config.age.secrets."jellyfin-favorites-sync-ssh-key".path;
+
       sshArgs = [
-        "-o StrictHostKeyChecking=accept-new"
+        "-o StrictHostKeyChecking=no"
+        "-o UserKnownHostsFile=/dev/null"
       ];
+
+      # Enable dry-run initially to verify sync operations
+      dryRun = true;
     };
     transmission = serviceDefaults // {
       enable = true;
tools/arr/lib.py
@@ -411,7 +411,10 @@ def print_final_summary(
 
 
 def select_with_fzf(
-    items: List[Dict[str, str]], display_format: str, multi: bool = True
+    items: List[Dict[str, str]],
+    display_format: str,
+    multi: bool = True,
+    enable_star_select: bool = False,
 ) -> List[str]:
     """
     Use fzf to interactively select items.
@@ -421,6 +424,7 @@ def select_with_fzf(
         display_format: Format string for displaying items (e.g.,
                        "{name} ({owner}, {tracks_total} tracks)")
         multi: Allow multiple selection if True
+        enable_star_select: Add Ctrl-S keybinding to select all ★ items
 
     Returns:
         List of selected item IDs (empty list if cancelled)
@@ -440,10 +444,17 @@ def select_with_fzf(
     fzf_input = "\n".join(lines)
 
     # Run fzf
-    fzf_args = ["fzf", "--ansi", "--prompt=Select playlists: "]
+    fzf_args = ["fzf", "--ansi", "--prompt=Select items: "]
     if multi:
         fzf_args.append("--multi")
 
+    # Add keybinding to select all starred items
+    if enable_star_select:
+        fzf_args.extend([
+            "--bind", "ctrl-s:select-all+accept",
+            "--header", "TAB: select | ENTER: confirm | Ctrl-S: select all ★ items"
+        ])
+
     try:
         result = subprocess.run(
             fzf_args,
@@ -766,7 +777,7 @@ class JellyfinClient:
             item_ids: List of item IDs to add
 
         Returns:
-            Response data
+            Response data (empty dict if no content)
         """
         params = {"ids": ",".join(item_ids), "userId": self.user_id}
         url = f"{self.base_url}/Playlists/{playlist_id}/Items"
@@ -775,6 +786,11 @@ class JellyfinClient:
                 url, headers=self.headers, params=params, timeout=30
             )
             response.raise_for_status()
+
+            # Handle empty responses (204 No Content)
+            if response.status_code == 204 or not response.text:
+                return {}
+
             return response.json()
         except requests.exceptions.RequestException as e:
             print(
@@ -917,6 +933,92 @@ class JellyfinClient:
         result = self.get(f"/Shows/{series_id}/Episodes", params=params)
         return result.get("Items", [])
 
+    def get_movies(
+        self,
+        fields: Optional[List[str]] = None,
+        sort_by: str = "SortName",
+    ) -> List[Dict[str, Any]]:
+        """
+        Get all movies in the library.
+
+        Args:
+            fields: Additional fields to include in response
+            sort_by: Sort field (default: SortName)
+
+        Returns:
+            List of movie items
+        """
+        if fields is None:
+            fields = ["ProductionYear", "CommunityRating", "Genres"]
+
+        params = {
+            "IncludeItemTypes": "Movie",
+            "Recursive": "true",
+            "Fields": ",".join(fields),
+            "SortBy": sort_by,
+        }
+
+        result = self.get(f"/Users/{self.user_id}/Items", params=params)
+        return result.get("Items", [])
+
+    def get_series(
+        self,
+        fields: Optional[List[str]] = None,
+        sort_by: str = "SortName",
+    ) -> List[Dict[str, Any]]:
+        """
+        Get all series in the library.
+
+        Args:
+            fields: Additional fields to include in response
+            sort_by: Sort field (default: SortName)
+
+        Returns:
+            List of series items
+        """
+        if fields is None:
+            fields = ["ProductionYear", "CommunityRating", "Genres"]
+
+        params = {
+            "IncludeItemTypes": "Series",
+            "Recursive": "true",
+            "Fields": ",".join(fields),
+            "SortBy": sort_by,
+        }
+
+        result = self.get(f"/Users/{self.user_id}/Items", params=params)
+        return result.get("Items", [])
+
+    def get_items_by_type(
+        self,
+        item_types: List[str],
+        fields: Optional[List[str]] = None,
+        sort_by: str = "SortName",
+    ) -> List[Dict[str, Any]]:
+        """
+        Get items of specified types from the library.
+
+        Args:
+            item_types: List of item types (e.g., ["Movie", "Series"])
+            fields: Additional fields to include in response
+            sort_by: Sort field (default: SortName)
+
+        Returns:
+            List of items
+        """
+        if fields is None:
+            fields = ["ProductionYear", "CommunityRating", "Genres"]
+
+        params = {
+            "IncludeItemTypes": ",".join(item_types),
+            "Recursive": "true",
+            "Fields": ",".join(fields),
+            "SortBy": sort_by,
+        }
+
+        result = self.get(f"/Users/{self.user_id}/Items", params=params)
+        return result.get("Items", [])
+
 
 class SpotifyClient:
     """Client for Spotify API interactions using client credentials flow."""
tools/jellyfin-favorites-sync/default.nix
@@ -27,6 +27,14 @@ python3.pkgs.buildPythonApplication {
     cp jellyfin-favorites-sync $out/bin/
     chmod +x $out/bin/jellyfin-favorites-sync
 
+    # Replace uv shebang with python3 for Nix packaging
+    substituteInPlace $out/bin/jellyfin-favorites-sync \
+      --replace-fail '#!/usr/bin/env -S uv run --script' '#!/usr/bin/env python3' \
+      --replace-fail '# /// script' '# (uv metadata removed for Nix packaging)' \
+      --replace-fail '# requires-python' '# (requires-python' \
+      --replace-fail '# dependencies' '# (dependencies' \
+      --replace-fail '# ///' '# )'
+
     # Wrap to add arr lib to PYTHONPATH and rsync/ssh to PATH
     wrapProgram $out/bin/jellyfin-favorites-sync \
       --prefix PYTHONPATH : "${../arr}" \
tools/jellyfin-favorites-sync/jellyfin-favorites-sync
@@ -190,8 +190,8 @@ def execute_rsync(
             rsync_cmd.append("--dry-run")
 
         # Add source and destination
-        rsync_cmd.append(f"{dest_user}@{dest_host}:{source_root}/")
-        rsync_cmd.append(f"{dest_root}/")
+        rsync_cmd.append(f"{source_root}/")  # Local source
+        rsync_cmd.append(f"{dest_user}@{dest_host}:{dest_root}/")  # Remote destination
 
         # Show command if verbose
         if verbose:
tools/jellyfin-manage-playlist/default.nix
@@ -0,0 +1,52 @@
+{
+  python3,
+  lib,
+  makeWrapper,
+}:
+
+python3.pkgs.buildPythonApplication {
+  pname = "jellyfin-manage-playlist";
+  version = "1.0.0";
+  format = "other";
+
+  src = ./.;
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  propagatedBuildInputs = with python3.pkgs; [
+    click
+    requests
+  ];
+
+  dontUsePythonImportsCheck = true;
+
+  installPhase = ''
+    runHook preInstall
+
+    mkdir -p $out/bin $out/lib/jellyfin-manage-playlist
+
+    # Copy the script
+    cp jellyfin-manage-playlist $out/bin/jellyfin-manage-playlist
+    chmod +x $out/bin/jellyfin-manage-playlist
+
+    # Copy the library from arr
+    cp ${../arr/lib.py} $out/lib/jellyfin-manage-playlist/lib.py
+
+    # Wrap to set PYTHONPATH
+    wrapProgram $out/bin/jellyfin-manage-playlist \
+      --prefix PYTHONPATH : "$out/lib/jellyfin-manage-playlist"
+
+    runHook postInstall
+  '';
+
+  meta = {
+    description = "Interactively manage Jellyfin playlists using fzf";
+    longDescription = ''
+      jellyfin-manage-playlist provides an interactive interface for
+      adding movies to Jellyfin playlists using fzf for selection.
+    '';
+    license = lib.licenses.mit;
+    platforms = lib.platforms.unix;
+    mainProgram = "jellyfin-manage-playlist";
+  };
+}
tools/jellyfin-manage-playlist/jellyfin-manage-playlist
@@ -0,0 +1,330 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+#     "requests>=2.31.0",
+#     "click>=8.1.0",
+# ]
+# ///
+
+"""
+Interactively manage Jellyfin playlists.
+
+Add movies to playlists using fzf for interactive selection.
+"""
+
+import os
+import sys
+from pathlib import Path
+from typing import List, Dict, Any
+
+import click
+
+# Import from shared arr library
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from lib import JellyfinClient, select_with_fzf
+
+
+def format_item(item: Dict[str, Any]) -> str:
+    """Format movie or series for display in fzf."""
+    name = item.get("Name", "Unknown")
+    year = item.get("ProductionYear", "")
+    rating = item.get("CommunityRating")
+    item_type = item.get("Type", "")
+
+    parts = [name]
+    if year:
+        parts.append(f"({year})")
+    if rating:
+        parts.append(f"★{rating:.1f}")
+
+    # Add type indicator for series
+    if item_type == "Series":
+        parts.append("[Series]")
+
+    return " ".join(parts)
+
+
+@click.command()
+@click.option(
+    "--jellyfin-url",
+    envvar="JELLYFIN_URL",
+    required=True,
+    help="Jellyfin server URL (e.g., http://localhost:8096)",
+)
+@click.option(
+    "--api-key",
+    envvar="JELLYFIN_API_KEY",
+    help="Jellyfin API key (or use --api-key-file)",
+)
+@click.option(
+    "--api-key-file",
+    type=click.Path(exists=True),
+    help="Path to file containing Jellyfin API key",
+)
+@click.option(
+    "--user-id",
+    envvar="JELLYFIN_USER_ID",
+    required=True,
+    help="Jellyfin user ID or username",
+)
+@click.option(
+    "--playlist-name",
+    help="Playlist name (will be created if it doesn't exist)",
+)
+@click.option(
+    "--item-type",
+    type=click.Choice(["movie", "series", "both"], case_sensitive=False),
+    default="movie",
+    help="Type of items to show (default: movie)",
+)
+@click.option(
+    "--dry-run",
+    is_flag=True,
+    help="Show what would be added without making changes",
+)
+@click.option(
+    "--verbose",
+    is_flag=True,
+    help="Enable verbose output",
+)
+def main(
+    jellyfin_url: str,
+    api_key: str,
+    api_key_file: str,
+    user_id: str,
+    playlist_name: str,
+    item_type: str,
+    dry_run: bool,
+    verbose: bool,
+):
+    """
+    Interactively add movies and/or series to a Jellyfin playlist.
+
+    Uses fzf for item selection. You can select multiple items and
+    add them to an existing playlist or create a new one.
+
+    \b
+    Example (movies only):
+      jellyfin-manage-playlist \\
+        --jellyfin-url http://localhost:8096 \\
+        --api-key-file ~/.secrets/jellyfin-api-key \\
+        --user-id vincent \\
+        --playlist-name "Keep"
+
+    \b
+    Example (movies and series):
+      jellyfin-manage-playlist \\
+        --jellyfin-url http://localhost:8096 \\
+        --api-key-file ~/.secrets/jellyfin-api-key \\
+        --user-id vincent \\
+        --item-type both \\
+        --playlist-name "Keep"
+    """
+    # Resolve API key
+    if api_key_file:
+        with open(api_key_file, "r") as f:
+            api_key = f.read().strip()
+    elif not api_key:
+        click.echo("Error: Either --api-key or --api-key-file must be provided", err=True)
+        sys.exit(1)
+
+    # Print header
+    click.echo("=" * 80)
+    click.echo("Jellyfin Playlist Manager")
+    click.echo("=" * 80)
+    click.echo(f"Server: {jellyfin_url}")
+    click.echo(f"User: {user_id}")
+    if dry_run:
+        click.echo("Mode: DRY RUN")
+    click.echo()
+
+    # Connect to Jellyfin
+    click.echo("Connecting to Jellyfin...")
+    try:
+        client = JellyfinClient(jellyfin_url, api_key, user_id, debug=verbose)
+    except Exception as e:
+        click.echo(f"✗ Failed to connect: {e}", err=True)
+        sys.exit(1)
+
+    # Get all playlists
+    click.echo("Fetching playlists...")
+    try:
+        playlists = client.get_playlists()
+        click.echo(f"Found {len(playlists)} playlists")
+    except Exception as e:
+        click.echo(f"✗ Failed to fetch playlists: {e}", err=True)
+        sys.exit(1)
+
+    # Select or create playlist
+    target_playlist_id = None
+    target_playlist_name = playlist_name
+
+    if playlist_name:
+        # Look for existing playlist
+        matched = [p for p in playlists if p.get("Name", "").lower() == playlist_name.lower()]
+        if matched:
+            target_playlist_id = matched[0].get("Id")
+            click.echo(f"Using existing playlist: {matched[0].get('Name')}")
+        else:
+            click.echo(f"Playlist '{playlist_name}' not found - will create it")
+    else:
+        # Let user select from existing playlists or create new
+        if playlists:
+            click.echo("\nExisting playlists:")
+            for i, p in enumerate(playlists, 1):
+                item_count = p.get("ChildCount", 0)
+                click.echo(f"  {i}. {p.get('Name')} ({item_count} items)")
+
+            choice = click.prompt("\nSelect playlist number or press Enter to create new",
+                                type=int, default=0, show_default=False)
+
+            if choice > 0 and choice <= len(playlists):
+                target_playlist_id = playlists[choice - 1].get("Id")
+                target_playlist_name = playlists[choice - 1].get("Name")
+                click.echo(f"Selected: {target_playlist_name}")
+            else:
+                target_playlist_name = click.prompt("Enter new playlist name")
+        else:
+            target_playlist_name = click.prompt("Enter new playlist name")
+
+    # Determine which item types to fetch
+    item_types = []
+    if item_type.lower() == "movie":
+        item_types = ["Movie"]
+    elif item_type.lower() == "series":
+        item_types = ["Series"]
+    else:  # both
+        item_types = ["Movie", "Series"]
+
+    # Get existing playlist items if playlist exists
+    existing_item_ids = set()
+    if target_playlist_id:
+        click.echo(f"\nFetching existing items in '{target_playlist_name}'...")
+        try:
+            existing_items = client.get_playlist_items_full(
+                target_playlist_id,
+                fields=["Type"]
+            )
+            existing_item_ids = {item.get("Id") for item in existing_items}
+            click.echo(f"Found {len(existing_item_ids)} items already in playlist")
+        except Exception as e:
+            click.echo(f"⚠ Failed to fetch existing items: {e}")
+
+    # Get all items
+    item_type_label = "movies" if item_type.lower() == "movie" else \
+                      "series" if item_type.lower() == "series" else "items"
+    click.echo(f"\nFetching {item_type_label} from library...")
+    try:
+        items = client.get_items_by_type(
+            item_types,
+            fields=["ProductionYear", "CommunityRating", "Genres", "Type"]
+        )
+        click.echo(f"Found {len(items)} {item_type_label}")
+    except Exception as e:
+        click.echo(f"✗ Failed to fetch {item_type_label}: {e}", err=True)
+        sys.exit(1)
+
+    if not items:
+        click.echo(f"⚠ No {item_type_label} found in library")
+        sys.exit(0)
+
+    # Prepare items for fzf selection
+    display_items = []
+    for item in items:
+        item_id = item.get("Id")
+        display = format_item(item)
+        in_playlist = item_id in existing_item_ids
+
+        # Mark items already in playlist
+        if in_playlist:
+            display = f"★ {display}"
+
+        display_items.append({
+            "id": item_id,
+            "display": display,
+            "name": item.get("Name", "Unknown"),
+            "in_playlist": in_playlist,
+        })
+
+    # Sort items: starred items (already in playlist) first, then alphabetically
+    display_items.sort(key=lambda x: (not x["in_playlist"], x["name"].lower()))
+
+    # Use fzf to select items
+    click.echo(f"\nOpening fzf for {item_type_label} selection...")
+    click.echo(f"(★ items are already in playlist and sorted to top)")
+
+    try:
+        selected_ids = select_with_fzf(
+            display_items,
+            display_format="{display}",
+            multi=True,
+            enable_star_select=bool(existing_item_ids),
+        )
+    except Exception as e:
+        click.echo(f"\n✗ Selection failed: {e}", err=True)
+        sys.exit(1)
+
+    if not selected_ids:
+        click.echo(f"\n⚠ No {item_type_label} selected. Exiting.")
+        sys.exit(0)
+
+    # Filter out items already in playlist (for existing playlists)
+    new_item_ids = [mid for mid in selected_ids if mid not in existing_item_ids]
+    already_in_playlist = [mid for mid in selected_ids if mid in existing_item_ids]
+
+    # Show selected items
+    selected_items = [m for m in display_items if m["id"] in selected_ids]
+    click.echo(f"\n✓ Selected {len(selected_items)} {item_type_label}:")
+    for item in selected_items:
+        click.echo(f"  - {item['display']}")
+
+    if already_in_playlist:
+        click.echo(f"\n⚠ {len(already_in_playlist)} already in playlist (will skip)")
+
+    if dry_run:
+        click.echo(f"\n[DRY RUN] Would add these {item_type_label} to playlist")
+        click.echo(f"Playlist: {target_playlist_name}")
+        click.echo(f"New items: {len(new_item_ids)}")
+        if already_in_playlist:
+            click.echo(f"Already in playlist: {len(already_in_playlist)}")
+        sys.exit(0)
+
+    # Create playlist if needed
+    if not target_playlist_id:
+        click.echo(f"\nCreating playlist '{target_playlist_name}'...")
+        try:
+            result = client.create_playlist(target_playlist_name, selected_ids)
+            target_playlist_id = result.get("Id")
+            click.echo(f"✓ Created playlist with {len(selected_ids)} {item_type_label}")
+        except Exception as e:
+            click.echo(f"✗ Failed to create playlist: {e}", err=True)
+            sys.exit(1)
+    else:
+        # Add to existing playlist (only new items)
+        if new_item_ids:
+            click.echo(f"\nAdding {len(new_item_ids)} new {item_type_label} to '{target_playlist_name}'...")
+            try:
+                client.add_to_playlist(target_playlist_id, new_item_ids)
+                click.echo(f"✓ Added {len(new_item_ids)} {item_type_label} to playlist")
+            except Exception as e:
+                click.echo(f"✗ Failed to add {item_type_label}: {e}", err=True)
+                sys.exit(1)
+        else:
+            click.echo(f"\n⚠ No new {item_type_label} to add (all selected items already in playlist)")
+
+    # Print summary
+    click.echo("\n" + "=" * 80)
+    click.echo("✓ Success!")
+    click.echo(f"  Playlist: {target_playlist_name}")
+    if target_playlist_id and existing_item_ids:
+        click.echo(f"  New items added: {len(new_item_ids)}")
+        if already_in_playlist:
+            click.echo(f"  Already in playlist: {len(already_in_playlist)}")
+    else:
+        click.echo(f"  Items added: {len(selected_ids)}")
+    click.echo("=" * 80)
+
+
+if __name__ == "__main__":
+    main(prog_name="jellyfin-manage-playlist")
tools/jellyfin-manage-playlist/README.md
@@ -0,0 +1,222 @@
+# Jellyfin Playlist Manager
+
+Interactively add movies to Jellyfin playlists using fzf for selection.
+
+## Overview
+
+This tool provides an interactive interface for managing Jellyfin playlists. It fetches movies and/or series from your Jellyfin library and lets you select which ones to add to a playlist using fzf's multi-select interface.
+
+## Features
+
+- **Interactive Selection**: Uses fzf for fast, fuzzy searching and multi-select
+- **Flexible Item Types**: Select movies only, series only, or both
+- **Metadata Display**: Shows name, year, rating, and type for easy identification
+- **Playlist Awareness**: Shows which items are already in the playlist with ★ marker
+- **Smart Duplicate Prevention**: Automatically skips items already in the playlist
+- **Playlist Management**: Create new playlists or add to existing ones
+- **Dry-Run Mode**: Preview selections without making changes
+
+## Usage
+
+### Basic Usage
+
+```bash
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Keep"
+```
+
+### Options
+
+- `--jellyfin-url`: Jellyfin server URL (required, or set JELLYFIN_URL env var)
+- `--api-key`: Jellyfin API key (use --api-key-file for secrets, or set JELLYFIN_API_KEY env var)
+- `--api-key-file`: Path to file containing Jellyfin API key (recommended)
+- `--user-id`: Jellyfin user ID or username (required, or set JELLYFIN_USER_ID env var)
+- `--playlist-name`: Playlist name (will be created if it doesn't exist)
+- `--item-type`: Type of items to show - `movie` (default), `series`, or `both`
+- `--dry-run`: Show what would be added without making changes
+- `--verbose`: Enable verbose output
+
+### Environment Variables
+
+You can set these environment variables to avoid passing them as arguments:
+
+```bash
+export JELLYFIN_URL="http://localhost:8096"
+export JELLYFIN_API_KEY="your-api-key-here"
+export JELLYFIN_USER_ID="vincent"
+
+# Then simply run:
+jellyfin-manage-playlist --playlist-name "Keep"
+```
+
+## Interactive Selection
+
+When you run the tool, it will:
+
+1. Connect to Jellyfin and fetch all movies/series
+2. Check which items are already in the target playlist (if it exists)
+3. Sort items so ★ items (already in playlist) appear first
+4. Open fzf with a list of items (showing name, year, rating, and type)
+5. Let you search and select items:
+   - **Type** to filter items
+   - **TAB** to select/deselect individual items
+   - **Ctrl-S** to select all ★ items at once (quick-add all starred items)
+   - **Enter** to confirm selection
+   - **Esc** to cancel
+6. Automatically skip items already in the playlist (no duplicates)
+
+Example fzf display (movies only, sorted):
+```
+★ The Dark Knight (2008) ★9.0     [already in playlist - sorted first]
+★ The Shawshank Redemption (1994) ★9.3   [already in playlist - sorted first]
+The Godfather (1972) ★9.2          [not in playlist - sorted after]
+Pulp Fiction (1994) ★8.9
+```
+
+Example fzf display (both movies and series, sorted):
+```
+★ Game of Thrones (2011) ★9.2 [Series]     [already in playlist - sorted first]
+★ The Shawshank Redemption (1994) ★9.3   [already in playlist - sorted first]
+Breaking Bad (2008) ★9.5 [Series]          [not in playlist - sorted after]
+The Godfather (1972) ★9.2                  [not in playlist - sorted after]
+```
+
+**Notes:**
+- The first ★ indicates the item is already in the playlist
+- The second ★ shows the rating
+- Series are marked with `[Series]` tag
+- Items already in playlist are sorted to the top for easy selection
+- Use **Ctrl-S** to select all ★ items at once
+
+## Examples
+
+### Create a New Playlist
+
+If the playlist doesn't exist, it will be created with the selected movies:
+
+```bash
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Weekend Movies"
+```
+
+### Add to Existing Playlist
+
+If the playlist exists, the selected movies will be added to it:
+
+```bash
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Keep"
+```
+
+### Interactive Playlist Selection
+
+If you don't specify a playlist name, the tool will let you choose from existing playlists or create a new one:
+
+```bash
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent
+```
+
+### Dry-Run Mode
+
+Preview your selections without making changes:
+
+```bash
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Keep" \
+  --dry-run
+```
+
+### Select Series Only
+
+Add TV series to a playlist:
+
+```bash
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Binge Watch" \
+  --item-type series
+```
+
+### Select Both Movies and Series
+
+Add both movies and series to the same playlist:
+
+```bash
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Watch Later" \
+  --item-type both
+```
+
+## Requirements
+
+- Python 3.11+
+- `fzf` command-line tool (fuzzy finder)
+- Jellyfin server with API access
+
+## Use Case: Syncing Favorites
+
+This tool works great with `jellyfin-favorites-sync` to manage which movies get synced to a remote host:
+
+1. Use `jellyfin-manage-playlist` to interactively add movies to a "Keep" playlist
+2. Configure `jellyfin-favorites-sync` to sync the "Keep" playlist to your NAS
+
+```bash
+# Step 1: Add movies to "Keep" playlist
+jellyfin-manage-playlist \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Keep"
+
+# Step 2: Sync the playlist to remote host
+jellyfin-favorites-sync \
+  --jellyfin-url http://localhost:8096 \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --playlist-name "Keep" \
+  --dest-host aix.sbr.pm
+```
+
+## Troubleshooting
+
+### fzf Not Found
+
+If you get an error about fzf not being found:
+
+- **NixOS**: `nix-env -iA nixpkgs.fzf` or add it to your system packages
+- **Other systems**: See https://github.com/junegunn/fzf for installation instructions
+
+### Connection Failed
+
+- Verify the Jellyfin URL is correct and accessible
+- Check that the API key is valid
+- Ensure the user ID exists in Jellyfin
+
+### No Movies Found
+
+- Check that your Jellyfin library contains movies
+- Verify the user has access to the movie libraries
+
+## License
+
+MIT
secrets.nix
@@ -122,6 +122,7 @@ in
   "secrets/rhea/jellyfin-auto-collections-api-key.age".publicKeys = users ++ [ rhea ];
   "secrets/rhea/jellyfin-auto-collections-jellyseerr-password.age".publicKeys = users ++ [ rhea ];
   "secrets/rhea/jellyfin-favorites-sync-api-key.age".publicKeys = users ++ [ rhea ];
+  "secrets/rhea/jellyfin-favorites-sync-ssh-key.age".publicKeys = users ++ [ rhea ];
   "secrets/rhea/webdav-password.age".publicKeys = users ++ [ rhea ];
   "secrets/sakhalin/grafana-admin-password.age".publicKeys = users ++ [ sakhalin ];
   "secrets/sakhalin/ntfy-token.age".publicKeys = users ++ [