main
  1#!/usr/bin/env -S uv run --script
  2# /// script
  3# requires-python = ">=3.11"
  4# dependencies = [
  5#     "requests>=2.31.0",
  6#     "click>=8.1.0",
  7# ]
  8# ///
  9
 10"""
 11Sync Jellyfin favorites to remote host via rsync.
 12
 13This script queries Jellyfin for favorited movies and series,
 14expands series to individual episodes, discovers parent directories
 15containing media files and metadata, and syncs them to a remote host using rsync.
 16"""
 17
 18import os
 19import subprocess
 20import sys
 21import tempfile
 22from pathlib import Path
 23from typing import Dict, Any, List, Set
 24
 25import click
 26
 27# Import from shared arr library
 28# Try packaged import first (PYTHONPATH set by wrapper), fall back to development import
 29try:
 30    from lib import JellyfinClient
 31except ImportError:
 32    # Development mode: add arr directory to path
 33    sys.path.insert(0, str(Path(__file__).parent.parent / "arr"))
 34    from lib import JellyfinClient
 35
 36
 37def expand_favorites(client: JellyfinClient, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 38    """
 39    Expand series items into individual episodes.
 40
 41    Args:
 42        client: Jellyfin client instance
 43        items: List of favorite items (may include series)
 44
 45    Returns:
 46        List of items with series expanded to episodes
 47    """
 48    expanded = []
 49    for item in items:
 50        if item.get("Type") == "Series":
 51            series_id = item.get("Id")
 52            series_name = item.get("Name")
 53            click.echo(f"  Expanding series: {series_name}")
 54            episodes = client.get_series_episodes(series_id, fields=["Path", "MediaSources"])
 55            click.echo(f"    Found {len(episodes)} episodes")
 56            expanded.extend(episodes)
 57        else:
 58            expanded.append(item)
 59    return expanded
 60
 61
 62def discover_sync_paths(items: List[Dict[str, Any]], source_root: Path, verbose: bool = False) -> Set[Path]:
 63    """
 64    Convert Jellyfin items to parent directories for rsync.
 65
 66    For movies: Parent directory (contains .mkv, .srt, .nfo, poster.jpg)
 67    For episodes: Season directory (contains all episodes + metadata)
 68
 69    Args:
 70        items: List of Jellyfin items (movies and episodes)
 71        source_root: Root path of Jellyfin library (e.g., /neo/videos)
 72        verbose: Enable verbose logging
 73
 74    Returns:
 75        Set of parent directories to sync
 76    """
 77    paths = set()
 78    skipped = []
 79
 80    for item in items:
 81        item_type = item.get("Type")
 82        item_name = item.get("Name", "Unknown")
 83
 84        # Get file path from item
 85        file_path_str = item.get("Path")
 86        if not file_path_str:
 87            media_sources = item.get("MediaSources", [])
 88            if media_sources and len(media_sources) > 0:
 89                file_path_str = media_sources[0].get("Path")
 90
 91        if not file_path_str:
 92            skipped.append(f"{item_name} ({item_type}): No path found")
 93            continue
 94
 95        file_path = Path(file_path_str)
 96
 97        # Validate path is within source root
 98        try:
 99            file_path.resolve().relative_to(source_root.resolve())
100        except ValueError:
101            skipped.append(f"{item_name} ({item_type}): Path outside source root")
102            continue
103
104        # Add parent directory
105        if item_type == "Movie":
106            paths.add(file_path.parent)
107            if verbose:
108                click.echo(f"    Movie: {item_name} -> {file_path.parent}")
109        elif item_type == "Episode":
110            paths.add(file_path.parent)
111            if verbose:
112                click.echo(f"    Episode: {item_name} -> {file_path.parent}")
113        else:
114            if verbose:
115                click.echo(f"    Skipping unknown type: {item_type}")
116
117    if skipped:
118        click.echo(f"\n⚠ Skipped {len(skipped)} items:")
119        for skip_reason in skipped[:10]:  # Show first 10
120            click.echo(f"  - {skip_reason}")
121        if len(skipped) > 10:
122            click.echo(f"  ... and {len(skipped) - 10} more")
123
124    return paths
125
126
127def execute_rsync(
128    sync_paths: Set[Path],
129    source_root: Path,
130    dest_user: str,
131    dest_host: str,
132    dest_root: str,
133    ssh_args: List[str],
134    dry_run: bool = False,
135    verbose: bool = False,
136) -> bool:
137    """
138    Execute rsync to sync directories to remote host.
139
140    Args:
141        sync_paths: Set of parent directories to sync
142        source_root: Root path of Jellyfin library
143        dest_user: SSH user for remote connection
144        dest_host: SSH hostname
145        dest_root: Destination path on remote host
146        ssh_args: Additional SSH arguments
147        dry_run: Show operations without executing
148        verbose: Enable verbose rsync output
149
150    Returns:
151        True if successful, False otherwise
152    """
153    if not sync_paths:
154        click.echo("⚠ No paths to sync!")
155        return False
156
157    # Generate temporary file list for rsync
158    with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
159        file_list_path = f.name
160        for path in sorted(sync_paths):
161            try:
162                relative_path = path.relative_to(source_root)
163                f.write(f"{relative_path}/\n")
164            except ValueError:
165                click.echo(f"⚠ Skipping path outside source root: {path}")
166
167    try:
168        # Build rsync command
169        rsync_cmd = [
170            "rsync",
171            "-aAX",  # Archive mode with ACLs and xattrs
172            "--delete",  # Mirror mode
173            "--delete-excluded",
174            f"--files-from={file_list_path}",
175            "--partial",  # Resume interrupted transfers
176            "--append-verify",  # Resume with verification
177            "--compress",  # SSH compression
178            "--info=progress2" if verbose else "--info=name1",
179            "--human-readable",
180        ]
181
182        # Add SSH command with custom args
183        if ssh_args:
184            ssh_cmd = "ssh " + " ".join(ssh_args)
185            rsync_cmd.append(f"-e")
186            rsync_cmd.append(ssh_cmd)
187
188        # Add dry-run flag if requested
189        if dry_run:
190            rsync_cmd.append("--dry-run")
191
192        # Add source and destination
193        rsync_cmd.append(f"{source_root}/")  # Local source
194        rsync_cmd.append(f"{dest_user}@{dest_host}:{dest_root}/")  # Remote destination
195
196        # Show command if verbose
197        if verbose:
198            click.echo(f"\nExecuting rsync command:")
199            click.echo(f"  {' '.join(rsync_cmd)}")
200            click.echo(f"\nFile list ({len(sync_paths)} directories):")
201            with open(file_list_path, "r") as f:
202                for line in f.read().splitlines()[:20]:
203                    click.echo(f"  {line}")
204                if len(sync_paths) > 20:
205                    click.echo(f"  ... and {len(sync_paths) - 20} more")
206            click.echo()
207
208        # Execute rsync
209        result = subprocess.run(rsync_cmd, check=False)
210
211        if result.returncode == 0:
212            return True
213        else:
214            click.echo(f"✗ Rsync failed with exit code {result.returncode}", err=True)
215            return False
216
217    finally:
218        # Clean up temporary file
219        try:
220            os.unlink(file_list_path)
221        except Exception:
222            pass
223
224
225@click.command(context_settings=dict(help_option_names=['-h', '--help']))
226@click.option(
227    "--jellyfin-url",
228    required=True,
229    help="Jellyfin server URL (e.g., https://jellyfin.sbr.pm)",
230)
231@click.option(
232    "--api-key",
233    help="Jellyfin API key (use --api-key-file for secrets)",
234)
235@click.option(
236    "--api-key-file",
237    type=click.Path(exists=True),
238    help="Path to file containing Jellyfin API key",
239)
240@click.option(
241    "--user-id",
242    required=True,
243    help="Jellyfin user ID or username",
244)
245@click.option(
246    "--playlist-id",
247    help="Jellyfin playlist ID to sync (instead of favorites)",
248)
249@click.option(
250    "--playlist-name",
251    help="Jellyfin playlist name to sync (instead of favorites)",
252)
253@click.option(
254    "--source-root",
255    type=click.Path(exists=True, file_okay=False),
256    default="/neo/videos",
257    help="Root path of Jellyfin library (default: /neo/videos)",
258)
259@click.option(
260    "--dest-host",
261    required=True,
262    help="Destination SSH host (e.g., aix.sbr.pm)",
263)
264@click.option(
265    "--dest-user",
266    default="vincent",
267    help="SSH user for remote connection (default: vincent)",
268)
269@click.option(
270    "--dest-root",
271    default="/data/favorites",
272    help="Destination path on remote host (default: /data/favorites)",
273)
274@click.option(
275    "--ssh-arg",
276    "ssh_args",
277    multiple=True,
278    help="Additional SSH arguments (can be specified multiple times)",
279)
280@click.option(
281    "--dry-run",
282    is_flag=True,
283    help="Show operations without executing",
284)
285@click.option(
286    "--verbose",
287    is_flag=True,
288    help="Enable verbose output",
289)
290def main(
291    jellyfin_url: str,
292    api_key: str,
293    api_key_file: str,
294    user_id: str,
295    playlist_id: str,
296    playlist_name: str,
297    source_root: str,
298    dest_host: str,
299    dest_user: str,
300    dest_root: str,
301    ssh_args: tuple,
302    dry_run: bool,
303    verbose: bool,
304):
305    """
306    Sync Jellyfin favorites to remote host via rsync.
307
308    This tool queries Jellyfin for favorited movies and series, expands series to
309    individual episodes, discovers parent directories containing media files and
310    metadata, and syncs them to a remote host using rsync in mirror mode.
311
312    \b
313    Example:
314      jellyfin-favorites-sync \\
315        --jellyfin-url https://jellyfin.sbr.pm \\
316        --api-key-file /run/agenix/jellyfin-api-key \\
317        --user-id vincent \\
318        --dest-host aix.sbr.pm \\
319        --dry-run \\
320        --verbose
321    """
322    # Resolve API key
323    if api_key_file:
324        with open(api_key_file, "r") as f:
325            api_key = f.read().strip()
326    elif not api_key:
327        click.echo("Error: Either --api-key or --api-key-file must be provided", err=True)
328        sys.exit(1)
329
330    # Convert paths
331    source_root_path = Path(source_root).resolve()
332
333    # Print header
334    click.echo("=" * 80)
335    if playlist_id or playlist_name:
336        click.echo("Jellyfin Playlist Sync")
337    else:
338        click.echo("Jellyfin Favorites Sync")
339    click.echo("=" * 80)
340    click.echo(f"Jellyfin URL: {jellyfin_url}")
341    click.echo(f"User ID: {user_id}")
342    click.echo(f"Source root: {source_root_path}")
343    click.echo(f"Destination: {dest_user}@{dest_host}:{dest_root}")
344    if dry_run:
345        click.echo("Mode: DRY RUN (no changes will be made)")
346    click.echo()
347
348    # Initialize Jellyfin client
349    click.echo("Connecting to Jellyfin...")
350    try:
351        client = JellyfinClient(jellyfin_url, api_key, user_id, debug=verbose)
352    except Exception as e:
353        click.echo(f"✗ Failed to connect to Jellyfin: {e}", err=True)
354        sys.exit(1)
355
356    # Resolve playlist name to ID if needed
357    resolved_playlist_id = playlist_id
358    if playlist_name and not playlist_id:
359        click.echo(f"Looking up playlist: {playlist_name}")
360        try:
361            playlists = client.get_playlists()
362            matched = [p for p in playlists if p.get("Name", "").lower() == playlist_name.lower()]
363            if matched:
364                resolved_playlist_id = matched[0].get("Id")
365                click.echo(f"Found playlist: {matched[0].get('Name')} (ID: {resolved_playlist_id})")
366            else:
367                click.echo(f"✗ Playlist '{playlist_name}' not found", err=True)
368                sys.exit(1)
369        except Exception as e:
370            click.echo(f"✗ Failed to look up playlist: {e}", err=True)
371            sys.exit(1)
372
373    # Query items (from playlist or favorites)
374    if resolved_playlist_id:
375        click.echo(f"Querying playlist items...")
376        try:
377            items = client.get_playlist_items_full(
378                resolved_playlist_id,
379                fields=["Path", "MediaSources"],
380            )
381            click.echo(f"Found {len(items)} items in playlist")
382        except Exception as e:
383            click.echo(f"✗ Failed to query playlist: {e}", err=True)
384            sys.exit(1)
385    else:
386        click.echo("Querying favorites...")
387        try:
388            items = client.get_favorites(
389                include_types=["Movie", "Series"],
390                fields=["Path", "MediaSources"],
391            )
392            click.echo(f"Found {len(items)} favorite items")
393        except Exception as e:
394            click.echo(f"✗ Failed to query favorites: {e}", err=True)
395            sys.exit(1)
396
397    if not items:
398        if resolved_playlist_id:
399            click.echo("⚠ No items in playlist. Nothing to sync.")
400        else:
401            click.echo("⚠ No favorites found. Nothing to sync.")
402        return
403
404    # Expand series to episodes
405    click.echo("\nExpanding series to episodes...")
406    try:
407        expanded = expand_favorites(client, items)
408        click.echo(f"Total items after expansion: {len(expanded)}")
409    except Exception as e:
410        click.echo(f"✗ Failed to expand series: {e}", err=True)
411        sys.exit(1)
412
413    # Discover sync paths
414    click.echo("\nDiscovering parent directories to sync...")
415    sync_paths = discover_sync_paths(expanded, source_root_path, verbose=verbose)
416    click.echo(f"Found {len(sync_paths)} directories to sync")
417
418    if not sync_paths:
419        click.echo("⚠ No valid paths to sync. Exiting.")
420        return
421
422    # Execute rsync
423    click.echo("\nSyncing to remote host...")
424    ssh_args_list = list(ssh_args) if ssh_args else []
425    success = execute_rsync(
426        sync_paths,
427        source_root_path,
428        dest_user,
429        dest_host,
430        dest_root,
431        ssh_args_list,
432        dry_run=dry_run,
433        verbose=verbose,
434    )
435
436    # Print summary
437    click.echo("\n" + "=" * 80)
438    if success:
439        if dry_run:
440            click.echo("✓ Dry-run completed successfully")
441        else:
442            click.echo("✓ Sync completed successfully")
443        if resolved_playlist_id:
444            click.echo(f"  Playlist items: {len(items)}")
445        else:
446            click.echo(f"  Favorites: {len(items)}")
447        click.echo(f"  Items (after series expansion): {len(expanded)}")
448        click.echo(f"  Directories synced: {len(sync_paths)}")
449    else:
450        click.echo("✗ Sync failed", err=True)
451        sys.exit(1)
452    click.echo("=" * 80)
453
454
455if __name__ == "__main__":
456    main(prog_name="jellyfin-favorites-sync")