Commit 6ab54f98d51a

Vincent Demeester <vincent@sbr.pm>
2025-12-09 17:08:14
feat(arr): Add interactive queue management for Lidarr
- Enable easy cleanup of stuck downloads requiring manual import - Provide detailed preview window to diagnose queue issues - Support batch removal with filtering by status (manual/error/warning/completed) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3adf5a1
Changed files (3)
tools/arr/commands/lidarr_manage_queue.py
@@ -0,0 +1,638 @@
+"""
+Manage Lidarr queue items with interactive selection.
+
+This script:
+1. Fetches all items from the Lidarr queue
+2. Filters for items that need manual import or have errors
+3. Displays them in an interactive selector (fzf)
+4. Allows removal of selected queue items
+"""
+
+import json
+import subprocess
+import sys
+from typing import Any, Dict, List
+
+from lib import (
+    ArrClient,
+    CommandContext,
+    get_confirmation_decision,
+    print_section_header,
+)
+
+
+def get_queue_items(
+    client: ArrClient,
+    include_unknown_artist: bool = True,
+    page_size: int = 1000,
+) -> List[Dict[str, Any]]:
+    """
+    Fetch all items from the Lidarr queue.
+
+    Args:
+        client: ArrClient instance
+        include_unknown_artist: Include items without matched artists
+        page_size: Number of items per page
+
+    Returns:
+        List of queue item dictionaries
+    """
+    params = {
+        "page": 1,
+        "pageSize": page_size,
+        "includeUnknownArtistItems": include_unknown_artist,
+        "includeAlbum": True,
+        "includeArtist": True,
+    }
+    response = client.get("/api/v1/queue", params=params)
+
+    # Handle paginated response
+    if isinstance(response, dict):
+        return response.get("records", [])
+    return response
+
+
+def delete_queue_item(
+    client: ArrClient,
+    queue_id: int,
+    remove_from_client: bool = True,
+    blocklist: bool = False,
+    skip_redownload: bool = False,
+) -> bool:
+    """
+    Delete a queue item.
+
+    Args:
+        client: ArrClient instance
+        queue_id: Queue item ID to delete
+        remove_from_client: Remove from download client
+        blocklist: Add to blocklist
+        skip_redownload: Skip automatic redownload
+
+    Returns:
+        True if successful
+    """
+    params = {
+        "removeFromClient": str(remove_from_client).lower(),
+        "blocklist": str(blocklist).lower(),
+        "skipRedownload": str(skip_redownload).lower(),
+    }
+
+    # Use requests directly for DELETE with query params
+    import requests
+
+    url = f"{client.base_url}/api/v1/queue/{queue_id}"
+    try:
+        response = requests.delete(
+            url, headers=client.headers, params=params, timeout=30
+        )
+        response.raise_for_status()
+        return True
+    except requests.exceptions.RequestException as e:
+        print(f"Error deleting queue item {queue_id}: {e}")
+        return False
+
+
+def format_queue_item(item: Dict[str, Any]) -> str:
+    """
+    Format a queue item for display.
+
+    Args:
+        item: Queue item dictionary
+
+    Returns:
+        Formatted string for display
+    """
+    queue_id = item.get("id", "?")
+    status = item.get("status", "unknown")
+    error_message = item.get("errorMessage", "")
+
+    # Get artist and album info if available
+    artist_name = "Unknown Artist"
+    album_title = "Unknown Album"
+
+    if "artist" in item and item["artist"]:
+        artist_name = item["artist"].get("artistName", artist_name)
+
+    if "album" in item and item["album"]:
+        album_title = item["album"].get("title", album_title)
+
+    # Get quality info
+    quality = "Unknown"
+    if "quality" in item and item["quality"]:
+        quality_profile = item["quality"].get("quality", {})
+        quality = quality_profile.get("name", "Unknown")
+
+    # Get size info
+    size_str = ""
+    size = item.get("size", 0)
+    sizeleft = item.get("sizeleft", 0)
+    if size > 0:
+        size_mb = size / (1024 * 1024)
+        if sizeleft > 0:
+            percent = ((size - sizeleft) / size) * 100
+            size_str = f"{size_mb:.1f}MB ({percent:.0f}%)"
+        else:
+            size_str = f"{size_mb:.1f}MB"
+
+    # Get protocol and download client
+    protocol = item.get("protocol", "")
+    download_client = item.get("downloadClient", "")
+
+    # Get tracked download info
+    tracked_status = item.get("trackedDownloadStatus", "")
+
+    # Determine status indicator
+    status_icon = "⚠️"
+    if status == "warning":
+        status_icon = "⚠️"
+    elif status == "error":
+        status_icon = "❌"
+    elif "completed" in status.lower():
+        status_icon = "✓"
+    elif "downloading" in status.lower():
+        status_icon = "⬇"
+    elif "manual" in status.lower():
+        status_icon = "🔧"
+
+    # Build display string
+    parts = [
+        f"[{queue_id}]",
+        status_icon,
+        f"{artist_name} - {album_title}",
+    ]
+
+    # Add quality and size
+    details = []
+    if quality != "Unknown":
+        details.append(quality)
+    if size_str:
+        details.append(size_str)
+    if protocol:
+        details.append(protocol.upper())
+    if download_client:
+        details.append(f"via {download_client}")
+
+    if details:
+        parts.append(f"[{' | '.join(details)}]")
+
+    # Add status
+    status_parts = [status]
+    if tracked_status and tracked_status != status:
+        status_parts.append(tracked_status)
+    parts.append(f"({', '.join(status_parts)})")
+
+    if error_message:
+        # Truncate long error messages
+        error_short = (
+            error_message[:60] + "..."
+            if len(error_message) > 60
+            else error_message
+        )
+        parts.append(f"- {error_short}")
+
+    return " ".join(parts)
+
+
+def format_queue_item_preview(item: Dict[str, Any]) -> str:
+    """
+    Format a detailed preview of a queue item.
+
+    Args:
+        item: Queue item dictionary
+
+    Returns:
+        Formatted preview string with full details
+    """
+    lines = []
+
+    # Header
+    queue_id = item.get("id", "?")
+    title = item.get("title", "Unknown")
+    lines.append("=" * 80)
+    lines.append(f"QUEUE ITEM #{queue_id}")
+    lines.append("=" * 80)
+    lines.append("")
+
+    # Basic Info
+    lines.append("BASIC INFO:")
+    lines.append(f"  Title: {title}")
+
+    if "artist" in item and item["artist"]:
+        artist_name = item["artist"].get("artistName", "Unknown")
+        lines.append(f"  Artist: {artist_name}")
+
+    if "album" in item and item["album"]:
+        album_title = item["album"].get("title", "Unknown")
+        release_date = item["album"].get("releaseDate", "Unknown")
+        lines.append(f"  Album: {album_title}")
+        lines.append(f"  Release Date: {release_date}")
+
+    lines.append("")
+
+    # Download Info
+    lines.append("DOWNLOAD INFO:")
+    status = item.get("status", "unknown")
+    lines.append(f"  Status: {status}")
+
+    tracked_status = item.get("trackedDownloadStatus", "")
+    if tracked_status:
+        lines.append(f"  Tracked Status: {tracked_status}")
+
+    tracked_state = item.get("trackedDownloadState", "")
+    if tracked_state:
+        lines.append(f"  Tracked State: {tracked_state}")
+
+    protocol = item.get("protocol", "")
+    if protocol:
+        lines.append(f"  Protocol: {protocol.upper()}")
+
+    download_client = item.get("downloadClient", "")
+    if download_client:
+        lines.append(f"  Download Client: {download_client}")
+
+    # Size info
+    size = item.get("size", 0)
+    sizeleft = item.get("sizeleft", 0)
+    if size > 0:
+        size_mb = size / (1024 * 1024)
+        lines.append(f"  Total Size: {size_mb:.2f} MB")
+        if sizeleft > 0:
+            sizeleft_mb = sizeleft / (1024 * 1024)
+            percent = ((size - sizeleft) / size) * 100
+            downloaded_mb = size_mb - sizeleft_mb
+            lines.append(
+                f"  Downloaded: {downloaded_mb:.2f} MB ({percent:.1f}%)"
+            )
+            lines.append(f"  Remaining: {sizeleft_mb:.2f} MB")
+
+    lines.append("")
+
+    # Quality Info
+    if "quality" in item and item["quality"]:
+        lines.append("QUALITY:")
+        quality_profile = item["quality"].get("quality", {})
+        quality_name = quality_profile.get("name", "Unknown")
+        lines.append(f"  Quality: {quality_name}")
+        lines.append("")
+
+    # Output Path
+    output_path = item.get("outputPath", "")
+    if output_path:
+        lines.append("OUTPUT PATH:")
+        lines.append(f"  {output_path}")
+        lines.append("")
+
+    # Error/Warning Messages
+    error_message = item.get("errorMessage", "")
+    if error_message:
+        lines.append("ERROR MESSAGE:")
+        lines.append(f"  {error_message}")
+        lines.append("")
+
+    status_messages = item.get("statusMessages", [])
+    if status_messages:
+        lines.append("STATUS MESSAGES:")
+        for msg in status_messages:
+            msg_title = msg.get("title", "")
+            msg_messages = msg.get("messages", [])
+            if msg_title:
+                lines.append(f"  • {msg_title}")
+            for m in msg_messages:
+                lines.append(f"    - {m}")
+        lines.append("")
+
+    # Download ID
+    download_id = item.get("downloadId", "")
+    if download_id:
+        lines.append("DOWNLOAD ID:")
+        lines.append(f"  {download_id}")
+        lines.append("")
+
+    # Timestamps
+    lines.append("TIMESTAMPS:")
+    added = item.get("added", "")
+    if added:
+        lines.append(f"  Added: {added}")
+
+    estimated_completion = item.get("estimatedCompletionTime", "")
+    if estimated_completion:
+        lines.append(f"  Estimated Completion: {estimated_completion}")
+
+    return "\n".join(lines)
+
+
+def select_queue_items_with_preview(
+    items: List[Dict[str, Any]]
+) -> List[str]:
+    """
+    Use fzf with preview to interactively select queue items.
+
+    Args:
+        items: List of queue item dictionaries
+
+    Returns:
+        List of selected item IDs (empty list if cancelled)
+    """
+    if not items:
+        return []
+
+    # Create a temporary mapping file for preview
+    import tempfile
+
+    # Create lookup table: display text -> item data
+    lookup = {}
+    lines = []
+
+    for item in items:
+        item_id = str(item.get("id"))
+        display = format_queue_item(item)
+        lines.append(display)
+        lookup[display] = {
+            "id": item_id,
+            "preview": format_queue_item_preview(item)
+        }
+
+    # Prepare fzf input
+    fzf_input = "\n".join(lines)
+
+    # Create a temporary script for preview
+    with tempfile.NamedTemporaryFile(
+        mode='w', suffix='.json', delete=False
+    ) as f:
+        # Store the lookup data
+        preview_data = {
+            display: data["preview"] for display, data in lookup.items()
+        }
+        json.dump(preview_data, f)
+        preview_file = f.name
+
+    try:
+        # Run fzf with preview
+        # We'll use a simple approach: pass the preview text directly
+        fzf_args = [
+            "fzf",
+            "--ansi",
+            "--multi",
+            "--prompt=Select queue items (TAB to select, ENTER to confirm): ",
+            "--preview=echo {}",
+            "--preview-window=right:60%:wrap",
+            "--bind=ctrl-/:toggle-preview",
+        ]
+
+        # Create a Python script for preview
+        import os
+        preview_script = tempfile.NamedTemporaryFile(
+            mode='w', suffix='.py', delete=False
+        )
+        preview_script.write(f"""#!/usr/bin/env python3
+import json
+import sys
+
+preview_file = {repr(preview_file)}
+line = sys.argv[1] if len(sys.argv) > 1 else ""
+
+try:
+    with open(preview_file, 'r') as f:
+        data = json.load(f)
+    print(data.get(line, "No preview available"))
+except Exception as e:
+    print(f"Error: {{e}}")
+""")
+        preview_script.close()
+        os.chmod(preview_script.name, 0o755)
+
+        prompt = (
+            "Select queue items "
+            "(TAB to select, ENTER to confirm, Ctrl-/ to toggle preview): "
+        )
+        header = (
+            "TAB: select | ENTER: confirm | Ctrl-/: toggle preview | "
+            "Ctrl-↑/↓: scroll preview | ESC: cancel"
+        )
+        fzf_args = [
+            "fzf",
+            "--ansi",
+            "--multi",
+            f"--prompt={prompt}",
+            f"--preview={preview_script.name} {{}}",
+            "--preview-window=right:60%:wrap",
+            "--bind=ctrl-/:toggle-preview",
+            "--bind=ctrl-up:preview-page-up",
+            "--bind=ctrl-down:preview-page-down",
+            "--bind=ctrl-u:preview-half-page-up",
+            "--bind=ctrl-d:preview-half-page-down",
+            f"--header={header}"
+        ]
+
+        result = subprocess.run(
+            fzf_args,
+            input=fzf_input,
+            text=True,
+            capture_output=True,
+            check=True,
+        )
+
+        # Parse selected lines
+        selected_lines = result.stdout.strip().split("\n")
+        selected_ids = [
+            lookup[line]["id"]
+            for line in selected_lines
+            if line in lookup
+        ]
+
+        return selected_ids
+
+    except subprocess.CalledProcessError:
+        # User cancelled or fzf not found
+        return []
+    except FileNotFoundError:
+        print("Error: fzf not found. Please install fzf:", file=sys.stderr)
+        print(
+            "  On NixOS: nix-env -iA nixpkgs.fzf",
+            file=sys.stderr
+        )
+        print(
+            "  On other systems: see https://github.com/junegunn/fzf",
+            file=sys.stderr
+        )
+        sys.exit(1)
+    finally:
+        # Clean up temporary files
+        import os
+        try:
+            os.unlink(preview_file)
+            os.unlink(preview_script.name)
+        except Exception:
+            pass
+
+
+def filter_queue_items(
+    items: List[Dict[str, Any]], filter_type: str = "all"
+) -> List[Dict[str, Any]]:
+    """
+    Filter queue items by type.
+
+    Args:
+        items: List of queue items
+        filter_type: Filter type - 'all', 'manual', 'warning',
+                     'error', 'completed'
+
+    Returns:
+        Filtered list of queue items
+    """
+    if filter_type == "all":
+        return items
+
+    filtered = []
+    for item in items:
+        status = item.get("status", "").lower()
+        error_message = item.get("errorMessage", "").lower()
+        tracked_download_status = item.get("trackedDownloadStatus", "")
+        tracked_download_status = tracked_download_status.lower()
+
+        if filter_type == "manual":
+            # Items that need manual import
+            if (
+                "warning" in status
+                or "manual" in tracked_download_status
+                or "manual import" in error_message
+            ):
+                filtered.append(item)
+        elif filter_type == "warning":
+            if "warning" in status:
+                filtered.append(item)
+        elif filter_type == "error":
+            if "error" in status or item.get("errorMessage"):
+                filtered.append(item)
+        elif filter_type == "completed":
+            if "completed" in status or "completed" in tracked_download_status:
+                filtered.append(item)
+
+    return filtered
+
+
+def run(
+    url: str,
+    api_key: str,
+    filter_type: str,
+    remove_from_client: bool,
+    blocklist: bool,
+    skip_redownload: bool,
+    dry_run: bool,
+    no_confirm: bool,
+):
+    """Execute the lidarr manage-queue command."""
+    # Create client and context
+    client = ArrClient(url, api_key)
+    ctx = CommandContext(dry_run, no_confirm)
+
+    print_section_header("FETCHING LIDARR QUEUE")
+    print(f"Connecting to {client.base_url}...")
+
+    all_items = get_queue_items(client)
+    print(f"Found {len(all_items)} total queue items")
+
+    if not all_items:
+        print("\nQueue is empty!")
+        return
+
+    # Filter items based on filter type
+    filtered_items = filter_queue_items(all_items, filter_type)
+
+    if filter_type != "all":
+        print(
+            f"Filtered to {len(filtered_items)} items "
+            f"(filter: {filter_type})"
+        )
+
+    if not filtered_items:
+        print(f"\nNo items matching filter '{filter_type}'")
+        return
+
+    print_section_header("QUEUE ITEMS")
+
+    # Display summary by status
+    status_counts = {}
+    for item in filtered_items:
+        status = item.get("status", "unknown")
+        status_counts[status] = status_counts.get(status, 0) + 1
+
+    print("\nItems by status:")
+    for status, count in sorted(status_counts.items()):
+        print(f"  - {status}: {count}")
+
+    # Interactive selection with preview
+    print("\nOpening interactive selector with preview...")
+    print(
+        "Controls: TAB=select | Ctrl-/=toggle preview | "
+        "Ctrl-↑/↓=scroll preview | ENTER=confirm"
+    )
+
+    selected_ids = select_queue_items_with_preview(filtered_items)
+
+    if not selected_ids:
+        print("\nNo items selected. Exiting.")
+        return
+
+    print(f"\n{len(selected_ids)} item(s) selected for removal")
+
+    # Show what will be removed
+    print("\nItems to remove:")
+    for item_id in selected_ids:
+        item = next(
+            (i for i in filtered_items if str(i.get("id")) == item_id), None
+        )
+        if item:
+            print(f"  - {format_queue_item(item)}")
+
+    # Show removal options
+    print("\nRemoval options:")
+    print(f"  - Remove from download client: {remove_from_client}")
+    print(f"  - Add to blocklist: {blocklist}")
+    print(f"  - Skip automatic redownload: {skip_redownload}")
+
+    # Confirm deletion
+    prompt = f"\nRemove {len(selected_ids)} item(s) from queue?"
+    should_proceed = get_confirmation_decision(ctx, prompt)
+
+    if not should_proceed:
+        print("Cancelled.")
+        return
+
+    # Delete selected items
+    print_section_header("REMOVING QUEUE ITEMS")
+
+    success_count = 0
+    failed_count = 0
+
+    for item_id in selected_ids:
+        queue_id = int(item_id)
+        item = next(
+            (i for i in filtered_items if i.get("id") == queue_id), None
+        )
+        item_title = item.get("title", f"ID {queue_id}") if item else queue_id
+
+        print(f"\nRemoving: {item_title}")
+
+        if delete_queue_item(
+            client,
+            queue_id,
+            remove_from_client,
+            blocklist,
+            skip_redownload,
+        ):
+            print("  ✓ Removed successfully")
+            success_count += 1
+        else:
+            print("  ✗ Failed to remove")
+            failed_count += 1
+
+    # Final summary
+    print_section_header("FINAL SUMMARY")
+    print(f"\nItems selected: {len(selected_ids)}")
+    print(f"  - Successfully removed: {success_count}")
+    print(f"  - Failed: {failed_count}")
+
+    if success_count > 0:
+        print("\n✓ Queue items removed successfully")
tools/arr/arr
@@ -219,6 +219,60 @@ def lidarr_update_metadata_profile(url, api_key, profile, artist, dry_run, no_co
     lidarr_update_metadata_profile.run(url, api_key, profile, artist, dry_run, no_confirm)
 
 
+@lidarr.command("manage-queue")
+@click.argument("url")
+@click.argument("api_key")
+@click.option("--filter", "filter_type", default="all", type=click.Choice(["all", "manual", "warning", "error", "completed"]), help="Filter queue items by type (default: all)")
+@click.option("--remove-from-client/--keep-in-client", default=True, help="Remove from download client (default: yes)")
+@click.option("--blocklist/--no-blocklist", default=False, help="Add to blocklist (default: no)")
+@click.option("--skip-redownload/--allow-redownload", default=False, help="Skip automatic redownload (default: no)")
+@click.option("--dry-run", is_flag=True, help="Show what would be removed without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def lidarr_manage_queue(url, api_key, filter_type, remove_from_client, blocklist, skip_redownload, dry_run, no_confirm):
+    """Manage Lidarr queue items with interactive selection.
+
+    Browse and remove items from the Lidarr queue, with filtering options
+    for items that need manual import, have warnings, errors, or are completed.
+
+    URL: Lidarr server URL (e.g., http://localhost:8686)
+    API_KEY: Lidarr API key
+
+    Filter types:
+      - all: Show all queue items (default)
+      - manual: Items that need manual import
+      - warning: Items with warnings
+      - error: Items with errors
+      - completed: Items that have completed downloading
+
+    Examples:
+        # View and manage all queue items
+        arr lidarr manage-queue http://localhost:8686 your-api-key
+
+        # Show only items that need manual import
+        arr lidarr manage-queue http://localhost:8686 your-api-key \\
+            --filter manual
+
+        # Remove items with errors, add to blocklist
+        arr lidarr manage-queue http://localhost:8686 your-api-key \\
+            --filter error --blocklist
+
+        # Dry run to preview items
+        arr lidarr manage-queue http://localhost:8686 your-api-key \\
+            --filter manual --dry-run
+    """
+    from commands import lidarr_manage_queue
+    lidarr_manage_queue.run(
+        url,
+        api_key,
+        filter_type,
+        remove_from_client,
+        blocklist,
+        skip_redownload,
+        dry_run,
+        no_confirm
+    )
+
+
 @lidarr.command("sync-spotify")
 @click.argument("url")
 @click.option("--api-key", "-k", envvar="LIDARR_API_KEY", required=True, help="Lidarr API key")
tools/arr/README.md
@@ -490,6 +490,70 @@ Tip: Lower --match-threshold if too many false negatives. Default is 0.6.
 
 ### Lidarr
 
+#### Queue Management
+
+Manage items in the Lidarr queue with interactive selection. This is especially useful for handling items that need manual import or have errors.
+
+```bash
+# View and manage all queue items interactively
+arr lidarr manage-queue http://localhost:8686 your-api-key
+
+# Show only items that need manual import
+arr lidarr manage-queue http://localhost:8686 your-api-key --filter manual
+
+# Show only items with warnings
+arr lidarr manage-queue http://localhost:8686 your-api-key --filter warning
+
+# Show only items with errors
+arr lidarr manage-queue http://localhost:8686 your-api-key --filter error
+
+# Show only completed items
+arr lidarr manage-queue http://localhost:8686 your-api-key --filter completed
+
+# Remove items and add to blocklist
+arr lidarr manage-queue http://localhost:8686 your-api-key --blocklist
+
+# Keep items in download client when removing from queue
+arr lidarr manage-queue http://localhost:8686 your-api-key --keep-in-client
+
+# Dry run to preview without making changes
+arr lidarr manage-queue http://localhost:8686 your-api-key --filter manual --dry-run
+```
+
+**Filter types:**
+- `all`: Show all queue items (default)
+- `manual`: Items that need manual import
+- `warning`: Items with warnings
+- `error`: Items with errors
+- `completed`: Items that have completed downloading
+
+**Removal options:**
+- `--remove-from-client` (default): Remove the item from your download client
+- `--keep-in-client`: Leave the item in your download client
+- `--blocklist`: Add the release to blocklist to prevent re-downloading
+- `--skip-redownload`: Don't automatically search for a replacement
+
+**Interactive controls:**
+- `TAB`: Select/deselect a queue item
+- `↑/↓` or `j/k`: Navigate queue items
+- `ENTER`: Confirm selection
+- `Ctrl+/`: Toggle preview window (shows detailed information)
+- `Ctrl+↑/↓`: Scroll preview window up/down (page at a time)
+- `Ctrl+u/d`: Scroll preview window up/down (half page at a time)
+- `ESC` or `Ctrl+C`: Cancel
+- Type to filter items
+
+**Preview window shows:**
+- Full item details (artist, album, release date)
+- Download information (status, protocol, client)
+- File size and download progress
+- Quality settings
+- Output path
+- Error and status messages
+- Download ID and timestamps
+
+#### Other Lidarr Commands
+
 ```bash
 # Rename albums
 arr lidarr rename-albums http://localhost:8686 your-api-key