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")