Commit 2019fb965431
Changed files (11)
modules
jellyfin-favorites-sync
pkgs
secrets
systems
rhea
tools
arr
jellyfin-favorites-sync
jellyfin-manage-playlist
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 ++ [