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