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"""
11Interactively manage Jellyfin playlists.
12
13Add movies to playlists using fzf for interactive selection.
14"""
15
16import os
17import sys
18from pathlib import Path
19from typing import List, Dict, Any
20
21import click
22
23# Import from shared arr library
24sys.path.insert(0, str(Path(__file__).parent.parent))
25from lib import JellyfinClient, select_with_fzf
26
27
28def format_item(item: Dict[str, Any]) -> str:
29 """Format movie or series for display in fzf."""
30 name = item.get("Name", "Unknown")
31 year = item.get("ProductionYear", "")
32 rating = item.get("CommunityRating")
33 item_type = item.get("Type", "")
34
35 parts = [name]
36 if year:
37 parts.append(f"({year})")
38 if rating:
39 parts.append(f"★{rating:.1f}")
40
41 # Add type indicator for series
42 if item_type == "Series":
43 parts.append("[Series]")
44
45 return " ".join(parts)
46
47
48@click.command()
49@click.option(
50 "--jellyfin-url",
51 envvar="JELLYFIN_URL",
52 required=True,
53 help="Jellyfin server URL (e.g., http://localhost:8096)",
54)
55@click.option(
56 "--api-key",
57 envvar="JELLYFIN_API_KEY",
58 help="Jellyfin API key (or use --api-key-file)",
59)
60@click.option(
61 "--api-key-file",
62 type=click.Path(exists=True),
63 help="Path to file containing Jellyfin API key",
64)
65@click.option(
66 "--user-id",
67 envvar="JELLYFIN_USER_ID",
68 required=True,
69 help="Jellyfin user ID or username",
70)
71@click.option(
72 "--playlist-name",
73 help="Playlist name (will be created if it doesn't exist)",
74)
75@click.option(
76 "--item-type",
77 type=click.Choice(["movie", "series", "both"], case_sensitive=False),
78 default="movie",
79 help="Type of items to show (default: movie)",
80)
81@click.option(
82 "--action",
83 type=click.Choice(["add", "remove"], case_sensitive=False),
84 default="add",
85 help="Action to perform: add items to playlist or remove items from playlist (default: add)",
86)
87@click.option(
88 "--dry-run",
89 is_flag=True,
90 help="Show what would be changed without making changes",
91)
92@click.option(
93 "--verbose",
94 is_flag=True,
95 help="Enable verbose output",
96)
97def main(
98 jellyfin_url: str,
99 api_key: str,
100 api_key_file: str,
101 user_id: str,
102 playlist_name: str,
103 item_type: str,
104 action: str,
105 dry_run: bool,
106 verbose: bool,
107):
108 """
109 Interactively add or remove movies and/or series to/from a Jellyfin playlist.
110
111 Uses fzf for item selection. You can select multiple items and
112 add them to or remove them from an existing playlist.
113
114 \b
115 Example (add movies):
116 jellyfin-manage-playlist \\
117 --jellyfin-url http://localhost:8096 \\
118 --api-key-file ~/.secrets/jellyfin-api-key \\
119 --user-id vincent \\
120 --playlist-name "Keep"
121
122 \b
123 Example (remove movies and series):
124 jellyfin-manage-playlist \\
125 --jellyfin-url http://localhost:8096 \\
126 --api-key-file ~/.secrets/jellyfin-api-key \\
127 --user-id vincent \\
128 --item-type both \\
129 --action remove \\
130 --playlist-name "Keep"
131 """
132 # Resolve API key
133 if api_key_file:
134 with open(api_key_file, "r") as f:
135 api_key = f.read().strip()
136 elif not api_key:
137 click.echo("Error: Either --api-key or --api-key-file must be provided", err=True)
138 sys.exit(1)
139
140 # Print header
141 click.echo("=" * 80)
142 click.echo("Jellyfin Playlist Manager")
143 click.echo("=" * 80)
144 click.echo(f"Server: {jellyfin_url}")
145 click.echo(f"User: {user_id}")
146 if dry_run:
147 click.echo("Mode: DRY RUN")
148 click.echo()
149
150 # Connect to Jellyfin
151 click.echo("Connecting to Jellyfin...")
152 try:
153 client = JellyfinClient(jellyfin_url, api_key, user_id, debug=verbose)
154 except Exception as e:
155 click.echo(f"✗ Failed to connect: {e}", err=True)
156 sys.exit(1)
157
158 # Get all playlists
159 click.echo("Fetching playlists...")
160 try:
161 playlists = client.get_playlists()
162 click.echo(f"Found {len(playlists)} playlists")
163 except Exception as e:
164 click.echo(f"✗ Failed to fetch playlists: {e}", err=True)
165 sys.exit(1)
166
167 # Select or create playlist
168 target_playlist_id = None
169 target_playlist_name = playlist_name
170
171 if playlist_name:
172 # Look for existing playlist
173 matched = [p for p in playlists if p.get("Name", "").lower() == playlist_name.lower()]
174 if matched:
175 target_playlist_id = matched[0].get("Id")
176 click.echo(f"Using existing playlist: {matched[0].get('Name')}")
177 else:
178 click.echo(f"Playlist '{playlist_name}' not found - will create it")
179 else:
180 # Let user select from existing playlists or create new
181 if playlists:
182 click.echo("\nExisting playlists:")
183 for i, p in enumerate(playlists, 1):
184 item_count = p.get("ChildCount", 0)
185 click.echo(f" {i}. {p.get('Name')} ({item_count} items)")
186
187 choice = click.prompt("\nSelect playlist number or press Enter to create new",
188 type=int, default=0, show_default=False)
189
190 if choice > 0 and choice <= len(playlists):
191 target_playlist_id = playlists[choice - 1].get("Id")
192 target_playlist_name = playlists[choice - 1].get("Name")
193 click.echo(f"Selected: {target_playlist_name}")
194 else:
195 target_playlist_name = click.prompt("Enter new playlist name")
196 else:
197 target_playlist_name = click.prompt("Enter new playlist name")
198
199 # Determine which item types to fetch
200 item_types = []
201 if item_type.lower() == "movie":
202 item_types = ["Movie"]
203 elif item_type.lower() == "series":
204 item_types = ["Series"]
205 else: # both
206 item_types = ["Movie", "Series"]
207
208 # Get existing playlist items if playlist exists
209 existing_item_ids = set()
210 existing_series_ids = set() # Track which series have episodes in playlist
211 if target_playlist_id:
212 click.echo(f"\nFetching existing items in '{target_playlist_name}'...")
213 try:
214 existing_items = client.get_playlist_items_full(
215 target_playlist_id,
216 fields=["Type", "SeriesId"]
217 )
218 existing_item_ids = {item.get("Id") for item in existing_items}
219 # Track series that have episodes in the playlist
220 for item in existing_items:
221 series_id = item.get("SeriesId")
222 if series_id:
223 existing_series_ids.add(series_id)
224 click.echo(f"Found {len(existing_item_ids)} items already in playlist")
225 if verbose and existing_series_ids:
226 click.echo(f" Including episodes from {len(existing_series_ids)} series")
227 except Exception as e:
228 click.echo(f"⚠ Failed to fetch existing items: {e}")
229
230 # Get all items
231 item_type_label = "movies" if item_type.lower() == "movie" else \
232 "series" if item_type.lower() == "series" else "items"
233 click.echo(f"\nFetching {item_type_label} from library...")
234 try:
235 items = client.get_items_by_type(
236 item_types,
237 fields=["ProductionYear", "CommunityRating", "Genres", "Type"]
238 )
239 click.echo(f"Found {len(items)} {item_type_label}")
240 except Exception as e:
241 click.echo(f"✗ Failed to fetch {item_type_label}: {e}", err=True)
242 sys.exit(1)
243
244 if not items:
245 click.echo(f"⚠ No {item_type_label} found in library")
246 sys.exit(0)
247
248 # Prepare items for fzf selection
249 display_items = []
250 for item in items:
251 item_id = item.get("Id")
252 item_item_type = item.get("Type", "")
253 display = format_item(item)
254
255 # Check if item is in playlist:
256 # - For movies: check if movie ID is in playlist
257 # - For series: check if series ID has episodes in playlist
258 in_playlist = False
259 if item_item_type == "Series":
260 in_playlist = item_id in existing_series_ids
261 else:
262 in_playlist = item_id in existing_item_ids
263
264 # Mark items already in playlist
265 if in_playlist:
266 display = f"★ {display}"
267
268 display_items.append({
269 "id": item_id,
270 "display": display,
271 "name": item.get("Name", "Unknown"),
272 "in_playlist": in_playlist,
273 "type": item_item_type,
274 })
275
276 # For remove mode, filter to only show items in playlist
277 if action.lower() == "remove":
278 display_items = [item for item in display_items if item["in_playlist"]]
279 if not display_items:
280 click.echo(f"\n⚠ No {item_type_label} found in playlist. Nothing to remove.")
281 sys.exit(0)
282
283 # Sort items: starred items (already in playlist) first, then alphabetically
284 display_items.sort(key=lambda x: (not x["in_playlist"], x["name"].lower()))
285
286 # Use fzf to select items
287 action_verb = "remove" if action.lower() == "remove" else "add"
288 click.echo(f"\nOpening fzf for {item_type_label} selection ({action_verb} mode)...")
289 if action.lower() == "add":
290 click.echo(f"(★ items are already in playlist and sorted to top)")
291 else:
292 click.echo(f"(All shown items are in playlist)")
293
294 try:
295 selected_ids = select_with_fzf(
296 display_items,
297 display_format="{display}",
298 multi=True,
299 enable_star_select=bool(existing_item_ids) and action.lower() == "add",
300 )
301 except Exception as e:
302 click.echo(f"\n✗ Selection failed: {e}", err=True)
303 sys.exit(1)
304
305 if not selected_ids:
306 click.echo(f"\n⚠ No {item_type_label} selected. Exiting.")
307 sys.exit(0)
308
309 # Show selected items
310 selected_items = [m for m in display_items if m["id"] in selected_ids]
311 click.echo(f"\n✓ Selected {len(selected_items)} {item_type_label}:")
312 for item in selected_items:
313 click.echo(f" - {item['display']}")
314
315 # Handle add vs remove logic differently
316 if action.lower() == "remove":
317 # For remove mode, we need to find the actual playlist entry IDs
318 # For series, we need to remove the episodes, not the series ID
319 items_to_remove = []
320 series_to_remove = []
321
322 for selected_id in selected_ids:
323 # Find the selected item's type
324 item_data = next((item for item in selected_items if item["id"] == selected_id), None)
325 if item_data and item_data.get("type") == "Series":
326 series_to_remove.append(selected_id)
327 elif selected_id in existing_item_ids:
328 items_to_remove.append(selected_id)
329
330 # For series, find all episodes in the playlist
331 if series_to_remove:
332 click.echo(f"\nFinding episodes for {len(series_to_remove)} series in playlist...")
333 for item in existing_items:
334 if item.get("SeriesId") in series_to_remove:
335 items_to_remove.append(item.get("Id"))
336
337 if not items_to_remove:
338 click.echo(f"\n⚠ No items to remove from playlist")
339 sys.exit(0)
340
341 if verbose:
342 click.echo(f"Will remove {len(items_to_remove)} items from playlist")
343 else:
344 # For add mode, filter out items already in playlist
345 # For series, check if series has episodes in playlist
346 new_item_ids = []
347 already_in_playlist_ids = []
348
349 for selected_id in selected_ids:
350 item_data = next((item for item in selected_items if item["id"] == selected_id), None)
351 if item_data:
352 if item_data["in_playlist"]:
353 already_in_playlist_ids.append(selected_id)
354 else:
355 new_item_ids.append(selected_id)
356
357 if already_in_playlist_ids:
358 click.echo(f"\n⚠ {len(already_in_playlist_ids)} already in playlist (will skip)")
359
360 if dry_run:
361 if action.lower() == "remove":
362 click.echo(f"\n[DRY RUN] Would remove these items from playlist")
363 click.echo(f"Playlist: {target_playlist_name}")
364 click.echo(f"Items to remove: {len(items_to_remove)}")
365 else:
366 click.echo(f"\n[DRY RUN] Would add these {item_type_label} to playlist")
367 click.echo(f"Playlist: {target_playlist_name}")
368 click.echo(f"New items: {len(new_item_ids)}")
369 if already_in_playlist_ids:
370 click.echo(f"Already in playlist: {len(already_in_playlist_ids)}")
371 sys.exit(0)
372
373 # Handle remove mode
374 if action.lower() == "remove":
375 if not target_playlist_id:
376 click.echo(f"\n✗ Cannot remove items: playlist '{target_playlist_name}' does not exist")
377 sys.exit(1)
378
379 click.echo(f"\nRemoving {len(items_to_remove)} items from '{target_playlist_name}'...")
380 try:
381 success = client.remove_from_playlist(target_playlist_id, items_to_remove)
382 if success:
383 click.echo(f"✓ Removed {len(items_to_remove)} items from playlist")
384 else:
385 click.echo(f"✗ Failed to remove items from playlist", err=True)
386 sys.exit(1)
387 except Exception as e:
388 click.echo(f"✗ Failed to remove items: {e}", err=True)
389 sys.exit(1)
390 else:
391 # Handle add mode
392 # Create playlist if needed
393 if not target_playlist_id:
394 click.echo(f"\nCreating playlist '{target_playlist_name}'...")
395 try:
396 result = client.create_playlist(target_playlist_name, selected_ids)
397 target_playlist_id = result.get("Id")
398 click.echo(f"✓ Created playlist with {len(selected_ids)} {item_type_label}")
399 except Exception as e:
400 click.echo(f"✗ Failed to create playlist: {e}", err=True)
401 sys.exit(1)
402 else:
403 # Add to existing playlist (only new items)
404 if new_item_ids:
405 click.echo(f"\nAdding {len(new_item_ids)} new {item_type_label} to '{target_playlist_name}'...")
406 try:
407 client.add_to_playlist(target_playlist_id, new_item_ids)
408 click.echo(f"✓ Added {len(new_item_ids)} {item_type_label} to playlist")
409 except Exception as e:
410 click.echo(f"✗ Failed to add {item_type_label}: {e}", err=True)
411 sys.exit(1)
412 else:
413 click.echo(f"\n⚠ No new {item_type_label} to add (all selected items already in playlist)")
414
415 # Print summary
416 click.echo("\n" + "=" * 80)
417 click.echo("✓ Success!")
418 click.echo(f" Playlist: {target_playlist_name}")
419 if action.lower() == "remove":
420 click.echo(f" Items removed: {len(items_to_remove)}")
421 elif target_playlist_id and existing_item_ids:
422 click.echo(f" New items added: {len(new_item_ids)}")
423 if already_in_playlist_ids:
424 click.echo(f" Already in playlist: {len(already_in_playlist_ids)}")
425 else:
426 click.echo(f" Items added: {len(selected_ids)}")
427 click.echo("=" * 80)
428
429
430if __name__ == "__main__":
431 main(prog_name="jellyfin-manage-playlist")