Commit c7849d44465d

Vincent Demeester <vincent@sbr.pm>
2026-01-07 22:48:41
feat(jellyfin): Add remove mode and fix series detection in playlist manager
- Enable removing movies and series from playlists for cleanup workflows - Fix series not showing ★ marker by tracking episodes via SeriesId - Add remove_from_playlist method for granular playlist management Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 756514d
Changed files (2)
tools
arr
jellyfin-manage-playlist
tools/arr/lib.py
@@ -844,6 +844,37 @@ class JellyfinClient:
         result = self.get(f"/Users/{self.user_id}/Items", params=params)
         return result.get("Items", [])
 
+    def remove_from_playlist(
+        self, playlist_id: str, item_ids: List[str]
+    ) -> bool:
+        """
+        Remove specific items from a playlist.
+
+        Args:
+            playlist_id: Jellyfin playlist ID
+            item_ids: List of item IDs to remove
+
+        Returns:
+            True if successful
+        """
+        if not item_ids:
+            return True  # Nothing to remove
+
+        try:
+            url = (
+                f"{self.base_url}/Playlists/{playlist_id}/Items"
+                f"?EntryIds={','.join(item_ids)}"
+            )
+            response = requests.delete(url, headers=self.headers, timeout=30)
+            response.raise_for_status()
+            return True
+        except requests.exceptions.RequestException as e:
+            print(
+                f"Error removing items from playlist: {e}",
+                file=sys.stderr,
+            )
+            return False
+
     def clear_playlist(self, playlist_id: str) -> bool:
         """
         Remove all items from a playlist.
@@ -860,21 +891,8 @@ class JellyfinClient:
         if not item_ids:
             return True  # Already empty
 
-        # Remove all items
-        try:
-            url = (
-                f"{self.base_url}/Playlists/{playlist_id}/Items"
-                f"?EntryIds={','.join(item_ids)}"
-            )
-            response = requests.delete(url, headers=self.headers, timeout=30)
-            response.raise_for_status()
-            return True
-        except requests.exceptions.RequestException as e:
-            print(
-                f"Error clearing playlist: {e}",
-                file=sys.stderr,
-            )
-            return False
+        # Remove all items using the dedicated method
+        return self.remove_from_playlist(playlist_id, item_ids)
 
     def get_favorites(
         self,
tools/jellyfin-manage-playlist/jellyfin-manage-playlist
@@ -78,10 +78,16 @@ def format_item(item: Dict[str, Any]) -> str:
     default="movie",
     help="Type of items to show (default: movie)",
 )
+@click.option(
+    "--action",
+    type=click.Choice(["add", "remove"], case_sensitive=False),
+    default="add",
+    help="Action to perform: add items to playlist or remove items from playlist (default: add)",
+)
 @click.option(
     "--dry-run",
     is_flag=True,
-    help="Show what would be added without making changes",
+    help="Show what would be changed without making changes",
 )
 @click.option(
     "--verbose",
@@ -95,17 +101,18 @@ def main(
     user_id: str,
     playlist_name: str,
     item_type: str,
+    action: str,
     dry_run: bool,
     verbose: bool,
 ):
     """
-    Interactively add movies and/or series to a Jellyfin playlist.
+    Interactively add or remove movies and/or series to/from a Jellyfin playlist.
 
     Uses fzf for item selection. You can select multiple items and
-    add them to an existing playlist or create a new one.
+    add them to or remove them from an existing playlist.
 
     \b
-    Example (movies only):
+    Example (add movies):
       jellyfin-manage-playlist \\
         --jellyfin-url http://localhost:8096 \\
         --api-key-file ~/.secrets/jellyfin-api-key \\
@@ -113,12 +120,13 @@ def main(
         --playlist-name "Keep"
 
     \b
-    Example (movies and series):
+    Example (remove movies and series):
       jellyfin-manage-playlist \\
         --jellyfin-url http://localhost:8096 \\
         --api-key-file ~/.secrets/jellyfin-api-key \\
         --user-id vincent \\
         --item-type both \\
+        --action remove \\
         --playlist-name "Keep"
     """
     # Resolve API key
@@ -199,15 +207,23 @@ def main(
 
     # Get existing playlist items if playlist exists
     existing_item_ids = set()
+    existing_series_ids = set()  # Track which series have episodes in playlist
     if target_playlist_id:
         click.echo(f"\nFetching existing items in '{target_playlist_name}'...")
         try:
             existing_items = client.get_playlist_items_full(
                 target_playlist_id,
-                fields=["Type"]
+                fields=["Type", "SeriesId"]
             )
             existing_item_ids = {item.get("Id") for item in existing_items}
+            # Track series that have episodes in the playlist
+            for item in existing_items:
+                series_id = item.get("SeriesId")
+                if series_id:
+                    existing_series_ids.add(series_id)
             click.echo(f"Found {len(existing_item_ids)} items already in playlist")
+            if verbose and existing_series_ids:
+                click.echo(f"  Including episodes from {len(existing_series_ids)} series")
         except Exception as e:
             click.echo(f"⚠ Failed to fetch existing items: {e}")
 
@@ -233,8 +249,17 @@ def main(
     display_items = []
     for item in items:
         item_id = item.get("Id")
+        item_item_type = item.get("Type", "")
         display = format_item(item)
-        in_playlist = item_id in existing_item_ids
+
+        # Check if item is in playlist:
+        # - For movies: check if movie ID is in playlist
+        # - For series: check if series ID has episodes in playlist
+        in_playlist = False
+        if item_item_type == "Series":
+            in_playlist = item_id in existing_series_ids
+        else:
+            in_playlist = item_id in existing_item_ids
 
         # Mark items already in playlist
         if in_playlist:
@@ -245,21 +270,33 @@ def main(
             "display": display,
             "name": item.get("Name", "Unknown"),
             "in_playlist": in_playlist,
+            "type": item_item_type,
         })
 
+    # For remove mode, filter to only show items in playlist
+    if action.lower() == "remove":
+        display_items = [item for item in display_items if item["in_playlist"]]
+        if not display_items:
+            click.echo(f"\n⚠ No {item_type_label} found in playlist. Nothing to remove.")
+            sys.exit(0)
+
     # Sort items: starred items (already in playlist) first, then alphabetically
     display_items.sort(key=lambda x: (not x["in_playlist"], x["name"].lower()))
 
     # Use fzf to select items
-    click.echo(f"\nOpening fzf for {item_type_label} selection...")
-    click.echo(f"(★ items are already in playlist and sorted to top)")
+    action_verb = "remove" if action.lower() == "remove" else "add"
+    click.echo(f"\nOpening fzf for {item_type_label} selection ({action_verb} mode)...")
+    if action.lower() == "add":
+        click.echo(f"(★ items are already in playlist and sorted to top)")
+    else:
+        click.echo(f"(All shown items are in playlist)")
 
     try:
         selected_ids = select_with_fzf(
             display_items,
             display_format="{display}",
             multi=True,
-            enable_star_select=bool(existing_item_ids),
+            enable_star_select=bool(existing_item_ids) and action.lower() == "add",
         )
     except Exception as e:
         click.echo(f"\n✗ Selection failed: {e}", err=True)
@@ -269,58 +306,122 @@ def main(
         click.echo(f"\n⚠ No {item_type_label} selected. Exiting.")
         sys.exit(0)
 
-    # Filter out items already in playlist (for existing playlists)
-    new_item_ids = [mid for mid in selected_ids if mid not in existing_item_ids]
-    already_in_playlist = [mid for mid in selected_ids if mid in existing_item_ids]
-
     # Show selected items
     selected_items = [m for m in display_items if m["id"] in selected_ids]
     click.echo(f"\n✓ Selected {len(selected_items)} {item_type_label}:")
     for item in selected_items:
         click.echo(f"  - {item['display']}")
 
-    if already_in_playlist:
-        click.echo(f"\n⚠ {len(already_in_playlist)} already in playlist (will skip)")
+    # Handle add vs remove logic differently
+    if action.lower() == "remove":
+        # For remove mode, we need to find the actual playlist entry IDs
+        # For series, we need to remove the episodes, not the series ID
+        items_to_remove = []
+        series_to_remove = []
+
+        for selected_id in selected_ids:
+            # Find the selected item's type
+            item_data = next((item for item in selected_items if item["id"] == selected_id), None)
+            if item_data and item_data.get("type") == "Series":
+                series_to_remove.append(selected_id)
+            elif selected_id in existing_item_ids:
+                items_to_remove.append(selected_id)
+
+        # For series, find all episodes in the playlist
+        if series_to_remove:
+            click.echo(f"\nFinding episodes for {len(series_to_remove)} series in playlist...")
+            for item in existing_items:
+                if item.get("SeriesId") in series_to_remove:
+                    items_to_remove.append(item.get("Id"))
+
+        if not items_to_remove:
+            click.echo(f"\n⚠ No items to remove from playlist")
+            sys.exit(0)
+
+        if verbose:
+            click.echo(f"Will remove {len(items_to_remove)} items from playlist")
+    else:
+        # For add mode, filter out items already in playlist
+        # For series, check if series has episodes in playlist
+        new_item_ids = []
+        already_in_playlist_ids = []
+
+        for selected_id in selected_ids:
+            item_data = next((item for item in selected_items if item["id"] == selected_id), None)
+            if item_data:
+                if item_data["in_playlist"]:
+                    already_in_playlist_ids.append(selected_id)
+                else:
+                    new_item_ids.append(selected_id)
+
+        if already_in_playlist_ids:
+            click.echo(f"\n⚠ {len(already_in_playlist_ids)} already in playlist (will skip)")
 
     if dry_run:
-        click.echo(f"\n[DRY RUN] Would add these {item_type_label} to playlist")
-        click.echo(f"Playlist: {target_playlist_name}")
-        click.echo(f"New items: {len(new_item_ids)}")
-        if already_in_playlist:
-            click.echo(f"Already in playlist: {len(already_in_playlist)}")
+        if action.lower() == "remove":
+            click.echo(f"\n[DRY RUN] Would remove these items from playlist")
+            click.echo(f"Playlist: {target_playlist_name}")
+            click.echo(f"Items to remove: {len(items_to_remove)}")
+        else:
+            click.echo(f"\n[DRY RUN] Would add these {item_type_label} to playlist")
+            click.echo(f"Playlist: {target_playlist_name}")
+            click.echo(f"New items: {len(new_item_ids)}")
+            if already_in_playlist_ids:
+                click.echo(f"Already in playlist: {len(already_in_playlist_ids)}")
         sys.exit(0)
 
-    # Create playlist if needed
-    if not target_playlist_id:
-        click.echo(f"\nCreating playlist '{target_playlist_name}'...")
+    # Handle remove mode
+    if action.lower() == "remove":
+        if not target_playlist_id:
+            click.echo(f"\n✗ Cannot remove items: playlist '{target_playlist_name}' does not exist")
+            sys.exit(1)
+
+        click.echo(f"\nRemoving {len(items_to_remove)} items from '{target_playlist_name}'...")
         try:
-            result = client.create_playlist(target_playlist_name, selected_ids)
-            target_playlist_id = result.get("Id")
-            click.echo(f"✓ Created playlist with {len(selected_ids)} {item_type_label}")
+            success = client.remove_from_playlist(target_playlist_id, items_to_remove)
+            if success:
+                click.echo(f"✓ Removed {len(items_to_remove)} items from playlist")
+            else:
+                click.echo(f"✗ Failed to remove items from playlist", err=True)
+                sys.exit(1)
         except Exception as e:
-            click.echo(f"✗ Failed to create playlist: {e}", err=True)
+            click.echo(f"✗ Failed to remove items: {e}", err=True)
             sys.exit(1)
     else:
-        # Add to existing playlist (only new items)
-        if new_item_ids:
-            click.echo(f"\nAdding {len(new_item_ids)} new {item_type_label} to '{target_playlist_name}'...")
+        # Handle add mode
+        # Create playlist if needed
+        if not target_playlist_id:
+            click.echo(f"\nCreating playlist '{target_playlist_name}'...")
             try:
-                client.add_to_playlist(target_playlist_id, new_item_ids)
-                click.echo(f"✓ Added {len(new_item_ids)} {item_type_label} to playlist")
+                result = client.create_playlist(target_playlist_name, selected_ids)
+                target_playlist_id = result.get("Id")
+                click.echo(f"✓ Created playlist with {len(selected_ids)} {item_type_label}")
             except Exception as e:
-                click.echo(f"✗ Failed to add {item_type_label}: {e}", err=True)
+                click.echo(f"✗ Failed to create playlist: {e}", err=True)
                 sys.exit(1)
         else:
-            click.echo(f"\n⚠ No new {item_type_label} to add (all selected items already in playlist)")
+            # Add to existing playlist (only new items)
+            if new_item_ids:
+                click.echo(f"\nAdding {len(new_item_ids)} new {item_type_label} to '{target_playlist_name}'...")
+                try:
+                    client.add_to_playlist(target_playlist_id, new_item_ids)
+                    click.echo(f"✓ Added {len(new_item_ids)} {item_type_label} to playlist")
+                except Exception as e:
+                    click.echo(f"✗ Failed to add {item_type_label}: {e}", err=True)
+                    sys.exit(1)
+            else:
+                click.echo(f"\n⚠ No new {item_type_label} to add (all selected items already in playlist)")
 
     # Print summary
     click.echo("\n" + "=" * 80)
     click.echo("✓ Success!")
     click.echo(f"  Playlist: {target_playlist_name}")
-    if target_playlist_id and existing_item_ids:
+    if action.lower() == "remove":
+        click.echo(f"  Items removed: {len(items_to_remove)}")
+    elif target_playlist_id and existing_item_ids:
         click.echo(f"  New items added: {len(new_item_ids)}")
-        if already_in_playlist:
-            click.echo(f"  Already in playlist: {len(already_in_playlist)}")
+        if already_in_playlist_ids:
+            click.echo(f"  Already in playlist: {len(already_in_playlist_ids)}")
     else:
         click.echo(f"  Items added: {len(selected_ids)}")
     click.echo("=" * 80)