Commit c7849d44465d
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)