Commit 3858476dd214

Vincent Demeester <vincent@sbr.pm>
2025-12-26 18:40:30
feat(jellyfin): Add favorites sync tool with NixOS module
- Enable automatic mirroring of favorited media to remote storage - Sync complete directories including metadata and subtitles - Provide scheduled execution via systemd timer with security hardening Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 1e9e0d1
Changed files (9)
modules/jellyfin-favorites-sync/default.nix
@@ -0,0 +1,189 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+
+with lib;
+
+let
+  cfg = config.services.jellyfin-favorites-sync;
+
+  # Convert schedule shortcuts to systemd OnCalendar format
+  scheduleToCalendar =
+    schedule:
+    if schedule == "hourly" then
+      "hourly"
+    else if schedule == "daily" then
+      "daily"
+    else if schedule == "weekly" then
+      "weekly"
+    else
+      schedule;
+
+in
+{
+  options.services.jellyfin-favorites-sync = {
+    enable = mkEnableOption "Jellyfin favorites sync service";
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.jellyfin-favorites-sync;
+      defaultText = literalExpression "pkgs.jellyfin-favorites-sync";
+      description = "The jellyfin-favorites-sync package to use.";
+    };
+
+    schedule = mkOption {
+      type = types.str;
+      default = "daily";
+      description = ''
+        When to run the sync. Can be "hourly", "daily", "weekly", or a systemd OnCalendar format.
+        See systemd.time(7) for OnCalendar format details.
+      '';
+      example = "daily";
+    };
+
+    jellyfinUrl = mkOption {
+      type = types.str;
+      example = "https://jellyfin.sbr.pm";
+      description = "Jellyfin server URL";
+    };
+
+    apiKeyFile = mkOption {
+      type = types.path;
+      description = "Path to file containing Jellyfin API key (managed by agenix)";
+    };
+
+    userId = mkOption {
+      type = types.str;
+      description = "Jellyfin user ID or username (will be auto-resolved to GUID)";
+    };
+
+    sourceRoot = mkOption {
+      type = types.str;
+      default = "/neo/videos";
+      description = "Root path of Jellyfin library on source host";
+    };
+
+    destination = {
+      host = mkOption {
+        type = types.str;
+        default = "aix.sbr.pm";
+        description = "Target SSH host for rsync";
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "vincent";
+        description = "SSH user for remote connection";
+      };
+
+      root = mkOption {
+        type = types.str;
+        default = "/data/favorites";
+        description = "Destination path on target host";
+      };
+    };
+
+    sshArgs = mkOption {
+      type = types.listOf types.str;
+      default = [ "-o StrictHostKeyChecking=accept-new" ];
+      description = "Additional SSH arguments";
+      example = [
+        "-p 2222"
+        "-i /home/jellyfin-favorites-sync/.ssh/id_ed25519"
+      ];
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = "jellyfin-favorites-sync";
+      description = "System user to run service as";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "jellyfin-favorites-sync";
+      description = "System group to run service as";
+    };
+
+    randomizedDelay = mkOption {
+      type = types.str;
+      default = "5m";
+      description = "Randomized delay before starting the job (systemd RandomizedDelaySec)";
+      example = "1h";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # Create system user/group
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      group = cfg.group;
+      description = "Jellyfin favorites sync service user";
+    };
+
+    users.groups.${cfg.group} = { };
+
+    # Systemd service
+    systemd.services.jellyfin-favorites-sync = {
+      description = "Jellyfin Favorites Sync";
+      after = [ "network-online.target" ];
+      wants = [ "network-online.target" ];
+
+      serviceConfig = {
+        Type = "oneshot";
+        User = cfg.user;
+        Group = cfg.group;
+
+        ExecStart = pkgs.writeShellScript "jellyfin-favorites-sync-start" ''
+          set -euo pipefail
+
+          # 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}" \
+            --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
+        '';
+
+        # Security hardening
+        PrivateTmp = true;
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        NoNewPrivileges = true;
+        ReadWritePaths = [ "/tmp" ]; # For rsync file lists
+
+        # Resource limits
+        Nice = 15;
+        IOSchedulingClass = "idle";
+      };
+
+      path = with pkgs; [
+        openssh
+        rsync
+      ];
+    };
+
+    # Systemd timer
+    systemd.timers.jellyfin-favorites-sync = {
+      description = "Timer for Jellyfin Favorites Sync";
+      wantedBy = [ "timers.target" ];
+
+      timerConfig = {
+        OnCalendar = scheduleToCalendar cfg.schedule;
+        Persistent = true;
+        RandomizedDelaySec = cfg.randomizedDelay;
+      };
+    };
+  };
+}
pkgs/default.nix
@@ -29,6 +29,7 @@ in
   homepage = pkgs.callPackage ./homepage { inherit globals; };
   audible-converter = pkgs.callPackage ./audible-converter { };
   jellyfin-auto-collections = pkgs.callPackage ./jellyfin-auto-collections { };
+  jellyfin-favorites-sync = pkgs.callPackage ../tools/jellyfin-favorites-sync { };
   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-api-key.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA AsF831YyFHKjujL9h29cvS0t7irQefKQqJS+oDNXes1/
+5LypPV5VJflmlWvC7yNkvCo/o86GT7OjaS4lH1Px5E4
+-> piv-p256 ViCCtQ A4T1vffDaJj4r0JZA8Rws9x1lXbLkn8L2jG9mT8OHPsA
+Wb5hh54CY/sqavxNU5jAMry1GAXmkIjECgG3EKBjPV4
+-> ssh-ed25519 EboMJg S8reVcuGXK4j0fXuaCXTiRsclDZaHYTIg5RXbl+mUzE
+1pDEgwaZ3+3esNA6r50HV68BNHHIzbIAata6Sdzgs5E
+--- NvzaJX4kSxGnSH4dWWiwJCVTJ/+d9GFNnd9ZkRiwFvU
+��rP5���X��L��K���]�jKõ�Sm	a%o�!�u�\bč$\y�����,��&�KznJM�
\ No newline at end of file
systems/rhea/extra.nix
@@ -76,6 +76,7 @@ in
     ../common/services/prometheus-exporters-node.nix
     ../common/services/prometheus-exporters-postgres.nix
     ../../modules/jellyfin-auto-collections
+    ../../modules/jellyfin-favorites-sync
   ];
 
   # Age secrets: gandi.env + webdav + jellyfin + generated exportarr secrets
@@ -100,6 +101,11 @@ in
       mode = "400";
       owner = "jellyfin-auto-collections";
     };
+    "jellyfin-favorites-sync-api-key" = {
+      file = ../../secrets/rhea/jellyfin-favorites-sync-api-key.age;
+      mode = "400";
+      owner = "jellyfin-favorites-sync";
+    };
     "restic-aix-password" = {
       file = ../../secrets/rhea/restic-aix-password.age;
       mode = "400";
@@ -593,6 +599,26 @@ in
         };
       };
     };
+    jellyfin-favorites-sync = {
+      enable = false;
+      schedule = "daily"; # Run daily at midnight
+
+      jellyfinUrl = "http://localhost:8096";
+      apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
+      userId = "400fef4e0ab2448cb8a2bc8ca2facc4f"; # vincent user ID
+
+      sourceRoot = "/neo/videos";
+
+      destination = {
+        host = "aix.sbr.pm";
+        user = "vincent";
+        root = "/data/favorites";
+      };
+
+      sshArgs = [
+        "-o StrictHostKeyChecking=accept-new"
+      ];
+    };
     transmission = serviceDefaults // {
       enable = true;
       package = pkgs.transmission_4;
tools/arr/lib.py
@@ -165,10 +165,11 @@ class ArrClient:
                         file=sys.stderr,
                     )
                 else:
-                    print(
-                        f"Error posting to {endpoint}: {type(e).__name__} - {str(e)}",
-                        file=sys.stderr,
+                    error_msg = (
+                        f"Error posting to {endpoint}: "
+                        f"{type(e).__name__} - {str(e)}"
                     )
+                    print(error_msg, file=sys.stderr)
 
                 # Print payload for debugging
                 print(f"  Payload: {payload}", file=sys.stderr)
@@ -832,6 +833,63 @@ class JellyfinClient:
             )
             return False
 
+    def get_favorites(
+        self,
+        include_types: Optional[List[str]] = None,
+        fields: Optional[List[str]] = None,
+    ) -> List[Dict[str, Any]]:
+        """
+        Get all favorite items for the user.
+
+        Args:
+            include_types: List of item types to include
+                          (e.g., ["Movie", "Series"])
+                          Defaults to ["Movie", "Series"]
+            fields: Additional fields to include in response
+                   Defaults to ["Path", "MediaSources"]
+
+        Returns:
+            List of favorite items
+        """
+        if include_types is None:
+            include_types = ["Movie", "Series"]
+        if fields is None:
+            fields = ["Path", "MediaSources"]
+
+        params = {
+            "IsFavorite": "true",
+            "Recursive": "true",
+            "IncludeItemTypes": ",".join(include_types),
+            "Fields": ",".join(fields),
+        }
+
+        result = self.get(f"/Users/{self.user_id}/Items", params=params)
+        return result.get("Items", [])
+
+    def get_series_episodes(
+        self,
+        series_id: str,
+        fields: Optional[List[str]] = None,
+    ) -> List[Dict[str, Any]]:
+        """
+        Get all episodes for a series.
+
+        Args:
+            series_id: Jellyfin series ID
+            fields: Additional fields to include in response
+                   Defaults to ["Path", "MediaSources"]
+
+        Returns:
+            List of episode items
+        """
+        if fields is None:
+            fields = ["Path", "MediaSources"]
+
+        params = {"Fields": ",".join(fields)}
+
+        result = self.get(f"/Shows/{series_id}/Episodes", params=params)
+        return result.get("Items", [])
+
 
 class SpotifyClient:
     """Client for Spotify API interactions using client credentials flow."""
tools/jellyfin-favorites-sync/default.nix
@@ -0,0 +1,49 @@
+{
+  python3,
+  lib,
+  makeWrapper,
+  rsync,
+  openssh,
+}:
+
+python3.pkgs.buildPythonApplication {
+  pname = "jellyfin-favorites-sync";
+  version = "1.0.0";
+  format = "other";
+
+  src = ./.;
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  propagatedBuildInputs = with python3.pkgs; [
+    requests
+    click
+  ];
+
+  installPhase = ''
+    runHook preInstall
+
+    mkdir -p $out/bin
+    cp jellyfin-favorites-sync $out/bin/
+    chmod +x $out/bin/jellyfin-favorites-sync
+
+    # Wrap to add arr lib to PYTHONPATH and rsync/ssh to PATH
+    wrapProgram $out/bin/jellyfin-favorites-sync \
+      --prefix PYTHONPATH : "${../arr}" \
+      --prefix PATH : "${
+        lib.makeBinPath [
+          rsync
+          openssh
+        ]
+      }"
+
+    runHook postInstall
+  '';
+
+  meta = {
+    description = "Sync Jellyfin favorite items to remote host via rsync";
+    license = lib.licenses.mit;
+    platforms = lib.platforms.unix;
+    mainProgram = "jellyfin-favorites-sync";
+  };
+}
tools/jellyfin-favorites-sync/jellyfin-favorites-sync
@@ -0,0 +1,408 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+#     "requests>=2.31.0",
+#     "click>=8.1.0",
+# ]
+# ///
+
+"""
+Sync Jellyfin favorites to remote host via rsync.
+
+This script queries Jellyfin for favorited movies and series,
+expands series to individual episodes, discovers parent directories
+containing media files and metadata, and syncs them to a remote host using rsync.
+"""
+
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import Dict, Any, List, Set
+
+import click
+
+# Import from shared arr library
+# Try packaged import first (PYTHONPATH set by wrapper), fall back to development import
+try:
+    from lib import JellyfinClient
+except ImportError:
+    # Development mode: add arr directory to path
+    sys.path.insert(0, str(Path(__file__).parent.parent / "arr"))
+    from lib import JellyfinClient
+
+
+def expand_favorites(client: JellyfinClient, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """
+    Expand series items into individual episodes.
+
+    Args:
+        client: Jellyfin client instance
+        items: List of favorite items (may include series)
+
+    Returns:
+        List of items with series expanded to episodes
+    """
+    expanded = []
+    for item in items:
+        if item.get("Type") == "Series":
+            series_id = item.get("Id")
+            series_name = item.get("Name")
+            click.echo(f"  Expanding series: {series_name}")
+            episodes = client.get_series_episodes(series_id, fields=["Path", "MediaSources"])
+            click.echo(f"    Found {len(episodes)} episodes")
+            expanded.extend(episodes)
+        else:
+            expanded.append(item)
+    return expanded
+
+
+def discover_sync_paths(items: List[Dict[str, Any]], source_root: Path, verbose: bool = False) -> Set[Path]:
+    """
+    Convert Jellyfin items to parent directories for rsync.
+
+    For movies: Parent directory (contains .mkv, .srt, .nfo, poster.jpg)
+    For episodes: Season directory (contains all episodes + metadata)
+
+    Args:
+        items: List of Jellyfin items (movies and episodes)
+        source_root: Root path of Jellyfin library (e.g., /neo/videos)
+        verbose: Enable verbose logging
+
+    Returns:
+        Set of parent directories to sync
+    """
+    paths = set()
+    skipped = []
+
+    for item in items:
+        item_type = item.get("Type")
+        item_name = item.get("Name", "Unknown")
+
+        # Get file path from item
+        file_path_str = item.get("Path")
+        if not file_path_str:
+            media_sources = item.get("MediaSources", [])
+            if media_sources and len(media_sources) > 0:
+                file_path_str = media_sources[0].get("Path")
+
+        if not file_path_str:
+            skipped.append(f"{item_name} ({item_type}): No path found")
+            continue
+
+        file_path = Path(file_path_str)
+
+        # Validate path is within source root
+        try:
+            file_path.resolve().relative_to(source_root.resolve())
+        except ValueError:
+            skipped.append(f"{item_name} ({item_type}): Path outside source root")
+            continue
+
+        # Add parent directory
+        if item_type == "Movie":
+            paths.add(file_path.parent)
+            if verbose:
+                click.echo(f"    Movie: {item_name} -> {file_path.parent}")
+        elif item_type == "Episode":
+            paths.add(file_path.parent)
+            if verbose:
+                click.echo(f"    Episode: {item_name} -> {file_path.parent}")
+        else:
+            if verbose:
+                click.echo(f"    Skipping unknown type: {item_type}")
+
+    if skipped:
+        click.echo(f"\n⚠ Skipped {len(skipped)} items:")
+        for skip_reason in skipped[:10]:  # Show first 10
+            click.echo(f"  - {skip_reason}")
+        if len(skipped) > 10:
+            click.echo(f"  ... and {len(skipped) - 10} more")
+
+    return paths
+
+
+def execute_rsync(
+    sync_paths: Set[Path],
+    source_root: Path,
+    dest_user: str,
+    dest_host: str,
+    dest_root: str,
+    ssh_args: List[str],
+    dry_run: bool = False,
+    verbose: bool = False,
+) -> bool:
+    """
+    Execute rsync to sync directories to remote host.
+
+    Args:
+        sync_paths: Set of parent directories to sync
+        source_root: Root path of Jellyfin library
+        dest_user: SSH user for remote connection
+        dest_host: SSH hostname
+        dest_root: Destination path on remote host
+        ssh_args: Additional SSH arguments
+        dry_run: Show operations without executing
+        verbose: Enable verbose rsync output
+
+    Returns:
+        True if successful, False otherwise
+    """
+    if not sync_paths:
+        click.echo("⚠ No paths to sync!")
+        return False
+
+    # Generate temporary file list for rsync
+    with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+        file_list_path = f.name
+        for path in sorted(sync_paths):
+            try:
+                relative_path = path.relative_to(source_root)
+                f.write(f"{relative_path}/\n")
+            except ValueError:
+                click.echo(f"⚠ Skipping path outside source root: {path}")
+
+    try:
+        # Build rsync command
+        rsync_cmd = [
+            "rsync",
+            "-aAX",  # Archive mode with ACLs and xattrs
+            "--delete",  # Mirror mode
+            "--delete-excluded",
+            f"--files-from={file_list_path}",
+            "--partial",  # Resume interrupted transfers
+            "--append-verify",  # Resume with verification
+            "--compress",  # SSH compression
+            "--info=progress2" if verbose else "--info=name1",
+            "--human-readable",
+        ]
+
+        # Add SSH command with custom args
+        if ssh_args:
+            ssh_cmd = "ssh " + " ".join(ssh_args)
+            rsync_cmd.append(f"-e")
+            rsync_cmd.append(ssh_cmd)
+
+        # Add dry-run flag if requested
+        if dry_run:
+            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}/")
+
+        # Show command if verbose
+        if verbose:
+            click.echo(f"\nExecuting rsync command:")
+            click.echo(f"  {' '.join(rsync_cmd)}")
+            click.echo(f"\nFile list ({len(sync_paths)} directories):")
+            with open(file_list_path, "r") as f:
+                for line in f.read().splitlines()[:20]:
+                    click.echo(f"  {line}")
+                if len(sync_paths) > 20:
+                    click.echo(f"  ... and {len(sync_paths) - 20} more")
+            click.echo()
+
+        # Execute rsync
+        result = subprocess.run(rsync_cmd, check=False)
+
+        if result.returncode == 0:
+            return True
+        else:
+            click.echo(f"✗ Rsync failed with exit code {result.returncode}", err=True)
+            return False
+
+    finally:
+        # Clean up temporary file
+        try:
+            os.unlink(file_list_path)
+        except Exception:
+            pass
+
+
+@click.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.option(
+    "--jellyfin-url",
+    required=True,
+    help="Jellyfin server URL (e.g., https://jellyfin.sbr.pm)",
+)
+@click.option(
+    "--api-key",
+    help="Jellyfin API key (use --api-key-file for secrets)",
+)
+@click.option(
+    "--api-key-file",
+    type=click.Path(exists=True),
+    help="Path to file containing Jellyfin API key",
+)
+@click.option(
+    "--user-id",
+    required=True,
+    help="Jellyfin user ID or username",
+)
+@click.option(
+    "--source-root",
+    type=click.Path(exists=True, file_okay=False),
+    default="/neo/videos",
+    help="Root path of Jellyfin library (default: /neo/videos)",
+)
+@click.option(
+    "--dest-host",
+    required=True,
+    help="Destination SSH host (e.g., aix.sbr.pm)",
+)
+@click.option(
+    "--dest-user",
+    default="vincent",
+    help="SSH user for remote connection (default: vincent)",
+)
+@click.option(
+    "--dest-root",
+    default="/data/favorites",
+    help="Destination path on remote host (default: /data/favorites)",
+)
+@click.option(
+    "--ssh-arg",
+    "ssh_args",
+    multiple=True,
+    help="Additional SSH arguments (can be specified multiple times)",
+)
+@click.option(
+    "--dry-run",
+    is_flag=True,
+    help="Show operations without executing",
+)
+@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,
+    source_root: str,
+    dest_host: str,
+    dest_user: str,
+    dest_root: str,
+    ssh_args: tuple,
+    dry_run: bool,
+    verbose: bool,
+):
+    """
+    Sync Jellyfin favorites to remote host via rsync.
+
+    This tool queries Jellyfin for favorited movies and series, expands series to
+    individual episodes, discovers parent directories containing media files and
+    metadata, and syncs them to a remote host using rsync in mirror mode.
+
+    \b
+    Example:
+      jellyfin-favorites-sync \\
+        --jellyfin-url https://jellyfin.sbr.pm \\
+        --api-key-file /run/agenix/jellyfin-api-key \\
+        --user-id vincent \\
+        --dest-host aix.sbr.pm \\
+        --dry-run \\
+        --verbose
+    """
+    # 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)
+
+    # Convert paths
+    source_root_path = Path(source_root).resolve()
+
+    # Print header
+    click.echo("=" * 80)
+    click.echo("Jellyfin Favorites Sync")
+    click.echo("=" * 80)
+    click.echo(f"Jellyfin URL: {jellyfin_url}")
+    click.echo(f"User ID: {user_id}")
+    click.echo(f"Source root: {source_root_path}")
+    click.echo(f"Destination: {dest_user}@{dest_host}:{dest_root}")
+    if dry_run:
+        click.echo("Mode: DRY RUN (no changes will be made)")
+    click.echo()
+
+    # Initialize Jellyfin client
+    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 to Jellyfin: {e}", err=True)
+        sys.exit(1)
+
+    # Query favorites
+    click.echo("Querying favorites...")
+    try:
+        favorites = client.get_favorites(
+            include_types=["Movie", "Series"],
+            fields=["Path", "MediaSources"],
+        )
+        click.echo(f"Found {len(favorites)} favorite items")
+    except Exception as e:
+        click.echo(f"✗ Failed to query favorites: {e}", err=True)
+        sys.exit(1)
+
+    if not favorites:
+        click.echo("⚠ No favorites found. Nothing to sync.")
+        return
+
+    # Expand series to episodes
+    click.echo("\nExpanding series to episodes...")
+    try:
+        expanded = expand_favorites(client, favorites)
+        click.echo(f"Total items after expansion: {len(expanded)}")
+    except Exception as e:
+        click.echo(f"✗ Failed to expand series: {e}", err=True)
+        sys.exit(1)
+
+    # Discover sync paths
+    click.echo("\nDiscovering parent directories to sync...")
+    sync_paths = discover_sync_paths(expanded, source_root_path, verbose=verbose)
+    click.echo(f"Found {len(sync_paths)} directories to sync")
+
+    if not sync_paths:
+        click.echo("⚠ No valid paths to sync. Exiting.")
+        return
+
+    # Execute rsync
+    click.echo("\nSyncing to remote host...")
+    ssh_args_list = list(ssh_args) if ssh_args else []
+    success = execute_rsync(
+        sync_paths,
+        source_root_path,
+        dest_user,
+        dest_host,
+        dest_root,
+        ssh_args_list,
+        dry_run=dry_run,
+        verbose=verbose,
+    )
+
+    # Print summary
+    click.echo("\n" + "=" * 80)
+    if success:
+        if dry_run:
+            click.echo("✓ Dry-run completed successfully")
+        else:
+            click.echo("✓ Sync completed successfully")
+        click.echo(f"  Favorites: {len(favorites)}")
+        click.echo(f"  Items (after series expansion): {len(expanded)}")
+        click.echo(f"  Directories synced: {len(sync_paths)}")
+    else:
+        click.echo("✗ Sync failed", err=True)
+        sys.exit(1)
+    click.echo("=" * 80)
+
+
+if __name__ == "__main__":
+    main(prog_name="jellyfin-favorites-sync")
tools/jellyfin-favorites-sync/README.md
@@ -0,0 +1,157 @@
+# Jellyfin Favorites Sync
+
+Sync Jellyfin favorite movies and series to a remote host via rsync.
+
+## Overview
+
+This tool queries a Jellyfin server for favorited items (movies and TV series), expands series to individual episodes, discovers parent directories containing media files and metadata (subtitles, .nfo files, posters), and syncs them to a remote host using rsync in mirror mode.
+
+## Features
+
+- **Favorites Query**: Automatically discovers movies and series marked as favorite
+- **Series Expansion**: Expands favorited TV series to include all episodes
+- **Complete Sync**: Syncs parent directories to include all auxiliary files (.srt, .nfo, poster.jpg, etc.)
+- **Mirror Mode**: Uses rsync `--delete` to remove files when items are unfavorited
+- **Efficient**: Single rsync invocation using `--files-from` for large favorite sets
+- **Resumable**: Supports `--partial` and `--append-verify` for interrupted transfers
+- **Configurable**: Target host, paths, and SSH arguments all configurable
+
+## Usage
+
+### CLI
+
+```bash
+jellyfin-favorites-sync \
+  --jellyfin-url https://jellyfin.sbr.pm \
+  --api-key-file /run/agenix/jellyfin-api-key \
+  --user-id vincent \
+  --source-root /neo/videos \
+  --dest-host aix.sbr.pm \
+  --dest-user vincent \
+  --dest-root /data/favorites \
+  --dry-run \
+  --verbose
+```
+
+### Options
+
+- `--jellyfin-url`: Jellyfin server URL (required)
+- `--api-key`: Jellyfin API key (use --api-key-file for secrets)
+- `--api-key-file`: Path to file containing API key (recommended)
+- `--user-id`: Jellyfin user ID or username (required)
+- `--source-root`: Root path of Jellyfin library (default: /neo/videos)
+- `--dest-host`: Destination SSH host (required)
+- `--dest-user`: SSH user (default: vincent)
+- `--dest-root`: Destination path (default: /data/favorites)
+- `--ssh-arg`: Additional SSH arguments (can be repeated)
+- `--dry-run`: Show operations without executing
+- `--verbose`: Enable verbose output
+
+### NixOS Service
+
+Configure as a systemd service with scheduled execution:
+
+```nix
+services.jellyfin-favorites-sync = {
+  enable = true;
+  schedule = "daily";
+
+  jellyfinUrl = "http://localhost:8096";
+  apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
+  userId = "vincent";
+
+  sourceRoot = "/neo/videos";
+
+  destination = {
+    host = "aix.sbr.pm";
+    user = "vincent";
+    root = "/data/favorites";
+  };
+
+  sshArgs = [ "-o StrictHostKeyChecking=accept-new" ];
+};
+```
+
+## How It Works
+
+1. **Connect to Jellyfin**: Authenticate using API key
+2. **Query Favorites**: Fetch all favorited movies and series
+3. **Expand Series**: For each favorited series, fetch all episodes
+4. **Discover Paths**: Extract parent directories containing media + metadata
+5. **Generate File List**: Create rsync `--files-from` input
+6. **Execute Rsync**: Sync to remote host with `--delete` for mirror mode
+
+## Examples
+
+### Dry-Run to See What Would Be Synced
+
+```bash
+jellyfin-favorites-sync \
+  --jellyfin-url https://jellyfin.sbr.pm \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --dest-host aix.sbr.pm \
+  --dry-run \
+  --verbose
+```
+
+### Sync to Different Host
+
+```bash
+jellyfin-favorites-sync \
+  --jellyfin-url https://jellyfin.sbr.pm \
+  --api-key-file /run/agenix/jellyfin-api-key \
+  --user-id vincent \
+  --dest-host backup.example.com \
+  --dest-root /backups/jellyfin-favorites
+```
+
+### With Custom SSH Port
+
+```bash
+jellyfin-favorites-sync \
+  --jellyfin-url https://jellyfin.sbr.pm \
+  --api-key-file ~/.secrets/jellyfin-api-key \
+  --user-id vincent \
+  --dest-host aix.sbr.pm \
+  --ssh-arg "-p" \
+  --ssh-arg "2222"
+```
+
+## Requirements
+
+- Python 3.11+
+- `requests` library
+- `click` library
+- `rsync` command
+- `openssh` (for SSH transport)
+
+## Security
+
+- **API Key**: Stored in encrypted file via agenix, never logged
+- **Path Validation**: All paths validated within source root
+- **SSH**: Dedicated service user with restricted SSH key recommended
+
+## Troubleshooting
+
+### No Favorites Found
+
+- Verify user ID is correct (run `jellyfin-favorites-sync --verbose`)
+- Check that items are actually marked as favorite in Jellyfin UI
+- Ensure API key has correct permissions
+
+### Rsync Fails
+
+- Verify SSH connectivity: `ssh dest-user@dest-host`
+- Check destination path exists and has correct permissions
+- Use `--verbose` to see full rsync command
+
+### Series Not Expanding
+
+- Check Jellyfin logs for API errors
+- Verify series has episodes in Jellyfin library
+- Use `--verbose` to see API responses
+
+## License
+
+MIT
secrets.nix
@@ -121,6 +121,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/webdav-password.age".publicKeys = users ++ [ rhea ];
   "secrets/sakhalin/grafana-admin-password.age".publicKeys = users ++ [ sakhalin ];
   "secrets/sakhalin/ntfy-token.age".publicKeys = users ++ [