Commit 3858476dd214
Changed files (9)
modules
jellyfin-favorites-sync
pkgs
secrets
systems
rhea
tools
arr
jellyfin-favorites-sync
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 ++ [