Commit 0f727da4447b

Vincent Demeester <vincent@sbr.pm>
2025-12-02 12:08:24
feat: Add Lidarr artist monitoring and metadata profile update commands
- Enable bulk artist updates to avoid manual configuration in Lidarr UI - Support dual monitoring modes for existing and future album discovery - Allow metadata profile changes to control tracked album types Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 95deaaa
tools/arr/commands/lidarr_update_metadata_profile.py
@@ -0,0 +1,235 @@
+"""
+Update artist metadata profile settings in Lidarr.
+
+This script allows you to change the metadata profile for artists in Lidarr.
+You can update all artists or filter by name pattern.
+"""
+
+from typing import Any, Dict, List
+
+from lib import (
+    ArrClient,
+    CommandContext,
+    get_confirmation_decision,
+    print_item_list,
+    print_section_header,
+)
+
+
+def get_artists(client: ArrClient) -> List[Dict[str, Any]]:
+    """Get all artists from Lidarr."""
+    return client.get("/api/v1/artist")
+
+
+def get_metadata_profiles(client: ArrClient) -> List[Dict[str, Any]]:
+    """Get all metadata profiles from Lidarr."""
+    return client.get("/api/v1/metadataprofile")
+
+
+def filter_artists(
+    artists: List[Dict[str, Any]], pattern: str = None
+) -> List[Dict[str, Any]]:
+    """
+    Filter artists by name pattern.
+
+    Args:
+        artists: List of artist objects
+        pattern: Optional substring to match in artist names (case-insensitive)
+
+    Returns:
+        Filtered list of artists
+    """
+    if not pattern:
+        return artists
+
+    pattern_lower = pattern.lower()
+    return [
+        artist
+        for artist in artists
+        if pattern_lower in artist.get("artistName", "").lower()
+    ]
+
+
+def update_artist_metadata_profile(
+    client: ArrClient, artist: Dict[str, Any], metadata_profile_id: int
+) -> bool:
+    """
+    Update an artist's metadata profile.
+
+    Args:
+        client: Lidarr API client
+        artist: Artist object to update
+        metadata_profile_id: Metadata profile ID to set
+
+    Returns:
+        True if successful, False otherwise
+    """
+    # Update the artist object with new metadata profile
+    artist["metadataProfileId"] = metadata_profile_id
+
+    # Send PUT request to update the artist
+    result = client.put("/api/v1/artist", artist)
+
+    return bool(result and result.get("id"))
+
+
+def run(
+    lidarr_url: str,
+    lidarr_api_key: str,
+    metadata_profile_name: str,
+    artist_pattern: str,
+    dry_run: bool,
+    no_confirm: bool,
+):
+    """Execute the lidarr update-metadata-profile command."""
+    # Create client and context
+    lidarr = ArrClient(lidarr_url, lidarr_api_key)
+    ctx = CommandContext(dry_run, no_confirm)
+
+    # Fetch metadata profiles
+    print_section_header("FETCHING METADATA PROFILES")
+    print("Retrieving metadata profiles...")
+    metadata_profiles = get_metadata_profiles(lidarr)
+
+    if not metadata_profiles:
+        print("\nNo metadata profiles found in Lidarr!")
+        return
+
+    print(f"Found {len(metadata_profiles)} metadata profile(s):\n")
+    for profile in metadata_profiles:
+        print(f"  - {profile.get('name')} (ID: {profile.get('id')})")
+
+    # Find the target metadata profile
+    target_profile = None
+    if metadata_profile_name:
+        # Search by name (case-insensitive)
+        for profile in metadata_profiles:
+            profile_name = profile.get("name", "").lower()
+            if profile_name == metadata_profile_name.lower():
+                target_profile = profile
+                break
+
+        if not target_profile:
+            print(
+                f"\nError: Metadata profile '{metadata_profile_name}' "
+                "not found!"
+            )
+            print("\nAvailable profiles:")
+            for profile in metadata_profiles:
+                print(f"  - {profile.get('name')}")
+            return
+    else:
+        # Use the first profile as default
+        target_profile = metadata_profiles[0]
+        print(
+            f"\nNo profile specified, using default: "
+            f"{target_profile.get('name')}"
+        )
+
+    metadata_profile_id = target_profile.get("id")
+    metadata_profile_name = target_profile.get("name")
+
+    print(
+        f"\nTarget metadata profile: {metadata_profile_name} "
+        f"(ID: {metadata_profile_id})"
+    )
+
+    # Fetch all artists
+    print_section_header("FETCHING ARTISTS FROM LIDARR")
+    print("Retrieving artists...")
+    all_artists = get_artists(lidarr)
+
+    if not all_artists:
+        print("\nNo artists found in Lidarr!")
+        return
+
+    print(f"Found {len(all_artists)} total artists in Lidarr")
+
+    # Filter artists if pattern provided
+    if artist_pattern:
+        print(f"\nFiltering artists by pattern: '{artist_pattern}'")
+        filtered_artists = filter_artists(all_artists, artist_pattern)
+        print(f"Matched {len(filtered_artists)} artists")
+    else:
+        filtered_artists = all_artists
+        print("\nNo filter specified - will update ALL artists")
+
+    if not filtered_artists:
+        print("\nNo artists match the specified pattern!")
+        return
+
+    # Display artists to be updated
+    print_section_header("ARTISTS TO UPDATE")
+    artist_names = [
+        artist.get("artistName", "Unknown") for artist in filtered_artists
+    ]
+    prefix_msg = (
+        f"Artists that will be set to metadata profile "
+        f"'{metadata_profile_name}'"
+    )
+    print_item_list(artist_names, prefix_msg, max_display=20)
+
+    # Confirm operation
+    confirm_msg = (
+        f"\nUpdate metadata profile for {len(filtered_artists)} "
+        f"artist(s) to '{metadata_profile_name}'?"
+    )
+    if not get_confirmation_decision(ctx, confirm_msg):
+        if not ctx.dry_run:
+            print("Operation cancelled")
+        return
+
+    # Update artists
+    print_section_header("UPDATING ARTISTS")
+
+    updated_count = 0
+    failed_count = 0
+
+    for idx, artist in enumerate(filtered_artists, 1):
+        artist_name = artist.get("artistName", "Unknown")
+        current_profile_id = artist.get("metadataProfileId", "Unknown")
+        print(
+            f"[{idx}/{len(filtered_artists)}] {artist_name} "
+            f"(current profile ID: {current_profile_id})"
+        )
+
+        if not ctx.dry_run:
+            success = update_artist_metadata_profile(
+                lidarr, artist, metadata_profile_id
+            )
+            if success:
+                print(
+                    f"  ✓ Updated to metadata profile "
+                    f"'{metadata_profile_name}'"
+                )
+                updated_count += 1
+            else:
+                print("  ✗ Failed to update")
+                failed_count += 1
+        else:
+            print(
+                f"  [DRY RUN] Would update to metadata profile "
+                f"'{metadata_profile_name}'"
+            )
+            updated_count += 1
+
+    # Final summary
+    print_section_header("FINAL SUMMARY")
+    print(f"\nTotal artists: {len(all_artists)}")
+    print(f"Artists selected: {len(filtered_artists)}")
+    print(f"  - Successfully updated: {updated_count}")
+    if failed_count > 0:
+        print(f"  - Failed: {failed_count}")
+
+    if ctx.dry_run:
+        print(
+            "\n[DRY RUN] No changes were made. "
+            "Remove --dry-run to update artists."
+        )
+    elif updated_count > 0:
+        print(
+            f"\nArtists are now set to metadata profile: "
+            f"{metadata_profile_name}\n"
+            "This will affect which album types and releases are "
+            "monitored and downloaded."
+        )
tools/arr/commands/lidarr_update_monitoring.py
@@ -0,0 +1,203 @@
+"""
+Update artist monitoring settings in Lidarr.
+
+This script allows you to change the monitoring mode for artists in Lidarr.
+You can update all artists or filter by name pattern.
+"""
+
+from typing import Any, Dict, List
+
+from lib import (
+    ArrClient,
+    CommandContext,
+    get_confirmation_decision,
+    print_item_list,
+    print_section_header,
+)
+
+
+def get_artists(client: ArrClient) -> List[Dict[str, Any]]:
+    """Get all artists from Lidarr."""
+    return client.get("/api/v1/artist")
+
+
+def filter_artists(
+    artists: List[Dict[str, Any]], pattern: str = None
+) -> List[Dict[str, Any]]:
+    """
+    Filter artists by name pattern.
+
+    Args:
+        artists: List of artist objects
+        pattern: Optional substring to match in artist names (case-insensitive)
+
+    Returns:
+        Filtered list of artists
+    """
+    if not pattern:
+        return artists
+
+    pattern_lower = pattern.lower()
+    return [
+        artist
+        for artist in artists
+        if pattern_lower in artist.get("artistName", "").lower()
+    ]
+
+
+def update_artist_monitoring(
+    client: ArrClient,
+    artist: Dict[str, Any],
+    monitor_mode: str,
+    monitor_new_items: str = None,
+) -> bool:
+    """
+    Update an artist's monitoring settings.
+
+    Args:
+        client: Lidarr API client
+        artist: Artist object to update
+        monitor_mode: Monitoring mode (all, future, missing, existing, none)
+        monitor_new_items: Monitor new items mode (all, new, none) - optional
+
+    Returns:
+        True if successful, False otherwise
+    """
+    # Update the artist object with new monitoring settings
+    artist["addOptions"] = {"monitor": monitor_mode}
+
+    # Update monitorNewItems if specified
+    if monitor_new_items is not None:
+        artist["monitorNewItems"] = monitor_new_items
+
+    # Send PUT request to update the artist
+    result = client.put("/api/v1/artist", artist)
+
+    return bool(result and result.get("id"))
+
+
+def run(
+    lidarr_url: str,
+    lidarr_api_key: str,
+    monitor_mode: str,
+    monitor_new_items: str,
+    artist_pattern: str,
+    dry_run: bool,
+    no_confirm: bool,
+):
+    """Execute the lidarr update-monitoring command."""
+    # Create client and context
+    lidarr = ArrClient(lidarr_url, lidarr_api_key)
+    ctx = CommandContext(dry_run, no_confirm)
+
+    # Fetch all artists
+    print_section_header("FETCHING ARTISTS FROM LIDARR")
+    print("Retrieving artists...")
+    all_artists = get_artists(lidarr)
+
+    if not all_artists:
+        print("\nNo artists found in Lidarr!")
+        return
+
+    print(f"Found {len(all_artists)} total artists in Lidarr")
+
+    # Filter artists if pattern provided
+    if artist_pattern:
+        print(f"\nFiltering artists by pattern: '{artist_pattern}'")
+        filtered_artists = filter_artists(all_artists, artist_pattern)
+        print(f"Matched {len(filtered_artists)} artists")
+    else:
+        filtered_artists = all_artists
+        print("\nNo filter specified - will update ALL artists")
+
+    if not filtered_artists:
+        print("\nNo artists match the specified pattern!")
+        return
+
+    # Display artists to be updated
+    print_section_header("ARTISTS TO UPDATE")
+    artist_names = [
+        artist.get("artistName", "Unknown") for artist in filtered_artists
+    ]
+
+    # Build description of what will be updated
+    update_desc = f"monitor mode '{monitor_mode}'"
+    if monitor_new_items:
+        update_desc += f" and monitor new items '{monitor_new_items}'"
+
+    print_item_list(
+        artist_names,
+        f"Artists that will be set to {update_desc}",
+        max_display=20,
+    )
+
+    # Confirm operation
+    confirm_msg = (
+        f"\nUpdate monitoring for {len(filtered_artists)} artist(s) "
+        f"to '{monitor_mode}'"
+    )
+    if monitor_new_items:
+        confirm_msg += f" (monitor new items: '{monitor_new_items}')"
+    confirm_msg += "?"
+
+    if not get_confirmation_decision(ctx, confirm_msg):
+        if not ctx.dry_run:
+            print("Operation cancelled")
+        return
+
+    # Update artists
+    print_section_header("UPDATING ARTISTS")
+
+    updated_count = 0
+    failed_count = 0
+
+    for idx, artist in enumerate(filtered_artists, 1):
+        artist_name = artist.get("artistName", "Unknown")
+        current_monitor_new = artist.get("monitorNewItems", "Unknown")
+        print(
+            f"[{idx}/{len(filtered_artists)}] {artist_name} "
+            f"(current monitorNewItems: {current_monitor_new})"
+        )
+
+        if not ctx.dry_run:
+            success = update_artist_monitoring(
+                lidarr, artist, monitor_mode, monitor_new_items
+            )
+            if success:
+                msg = f"  ✓ Updated to monitor '{monitor_mode}'"
+                if monitor_new_items:
+                    msg += f" (monitor new items: '{monitor_new_items}')"
+                print(msg)
+                updated_count += 1
+            else:
+                print("  ✗ Failed to update")
+                failed_count += 1
+        else:
+            msg = f"  [DRY RUN] Would update to monitor '{monitor_mode}'"
+            if monitor_new_items:
+                msg += f" (monitor new items: '{monitor_new_items}')"
+            print(msg)
+            updated_count += 1
+
+    # Final summary
+    print_section_header("FINAL SUMMARY")
+    print(f"\nTotal artists: {len(all_artists)}")
+    print(f"Artists selected: {len(filtered_artists)}")
+    print(f"  - Successfully updated: {updated_count}")
+    if failed_count > 0:
+        print(f"  - Failed: {failed_count}")
+
+    if ctx.dry_run:
+        print(
+            "\n[DRY RUN] No changes were made. "
+            "Remove --dry-run to update artists."
+        )
+    elif updated_count > 0:
+        summary_msg = f"\nArtists are now set to monitor: {monitor_mode}"
+        if monitor_new_items:
+            summary_msg += f"\nMonitor new items: {monitor_new_items}"
+        summary_msg += (
+            "\nLidarr will automatically search for albums based on "
+            "these settings."
+        )
+        print(summary_msg)
tools/arr/arr
@@ -124,6 +124,95 @@ def lidarr_update_paths(url, api_key, music_folder, dry_run):
     lidarr_update_paths.run(url, api_key, music_folder, dry_run)
 
 
+@lidarr.command("update-monitoring")
+@click.argument("url")
+@click.argument("api_key")
+@click.option("--monitor", default="future", type=click.Choice(["all", "future", "missing", "existing", "none"]), help="Album monitoring mode (default: future)")
+@click.option("--monitor-new-items", type=click.Choice(["all", "new", "none"]), help="Monitor new items mode - controls automatic monitoring of albums added to MusicBrainz after the artist is in Lidarr")
+@click.option("--artist", "-a", help="Filter by artist name pattern (case-insensitive)")
+@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def lidarr_update_monitoring(url, api_key, monitor, monitor_new_items, artist, dry_run, no_confirm):
+    """Update artist monitoring settings.
+
+    Changes the monitoring mode for artists in Lidarr. By default updates
+    all artists to monitor 'future' albums only.
+
+    Monitoring modes (--monitor):
+      - all: Monitor all albums (past and future)
+      - future: Monitor only future albums (default)
+      - missing: Monitor only missing albums
+      - existing: Monitor only existing albums
+      - none: Don't monitor any albums
+
+    Monitor new items modes (--monitor-new-items):
+      Controls what happens with albums added to MusicBrainz AFTER the artist
+      is already in Lidarr (e.g., new releases or newly discovered old albums)
+      - all: Automatically monitor all new albums
+      - new: Monitor new albums
+      - none: Don't monitor new albums
+
+    Examples:
+        # Update all artists to monitor future albums only
+        arr lidarr update-monitoring http://localhost:8686 your-api-key
+
+        # Update specific artist pattern
+        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
+            --artist "Taylor Swift"
+
+        # Set all artists to monitor all albums
+        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
+            --monitor all
+
+        # Set all artists to monitor future albums and auto-monitor new items
+        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
+            --monitor future --monitor-new-items all
+
+        # Dry run to see what would change
+        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
+            --dry-run
+    """
+    from commands import lidarr_update_monitoring
+    lidarr_update_monitoring.run(url, api_key, monitor, monitor_new_items, artist, dry_run, no_confirm)
+
+
+@lidarr.command("update-metadata-profile")
+@click.argument("url")
+@click.argument("api_key")
+@click.option("--profile", "-p", help="Metadata profile name to apply (if not specified, lists available profiles)")
+@click.option("--artist", "-a", help="Filter by artist name pattern (case-insensitive)")
+@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def lidarr_update_metadata_profile(url, api_key, profile, artist, dry_run, no_confirm):
+    """Update artist metadata profile settings.
+
+    Changes the metadata profile for artists in Lidarr. Metadata profiles
+    control which release types and album types are monitored (e.g., studio
+    albums, EPs, live albums, etc.).
+
+    If no profile is specified, the command will list available profiles and
+    use the first one as default.
+
+    Examples:
+        # List available metadata profiles and update all artists to first one
+        arr lidarr update-metadata-profile http://localhost:8686 your-api-key
+
+        # Update all artists to a specific metadata profile
+        arr lidarr update-metadata-profile http://localhost:8686 your-api-key \\
+            --profile "Standard"
+
+        # Update specific artist pattern
+        arr lidarr update-metadata-profile http://localhost:8686 your-api-key \\
+            --profile "Standard" --artist "Taylor Swift"
+
+        # Dry run to see what would change
+        arr lidarr update-metadata-profile http://localhost:8686 your-api-key \\
+            --profile "Standard" --dry-run
+    """
+    from commands import lidarr_update_metadata_profile
+    lidarr_update_metadata_profile.run(url, api_key, profile, artist, 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/lib.py
@@ -191,6 +191,81 @@ class ArrClient:
 
         return {}
 
+    def put(
+        self,
+        endpoint: str,
+        payload: Dict[str, Any],
+        max_retries: int = 3,
+        retry_delay: float = 2.0,
+    ) -> Dict[str, Any]:
+        """
+        Make a PUT request to the *arr API with retry logic.
+
+        Args:
+            endpoint: API endpoint path (e.g., /api/v3/series)
+            payload: JSON payload to send
+            max_retries: Maximum number of retry attempts
+            retry_delay: Initial delay between retries (seconds)
+
+        Returns:
+            JSON response data (empty dict on failure)
+        """
+        url = f"{self.base_url}{endpoint}"
+        headers = {**self.headers, "Content-Type": "application/json"}
+
+        for attempt in range(max_retries):
+            try:
+                response = requests.put(
+                    url, headers=headers, json=payload, timeout=30
+                )
+                response.raise_for_status()
+                return response.json()
+            except requests.exceptions.HTTPError as e:
+                status_code = e.response.status_code if e.response else None
+
+                # Retry on server errors (5xx) or rate limiting (429)
+                if status_code in [429, 500, 502, 503, 504]:
+                    if attempt < max_retries - 1:
+                        wait_time = retry_delay * (2**attempt)
+                        print(
+                            f"  Server error ({status_code}), "
+                            f"retrying in {wait_time}s... "
+                            f"(attempt {attempt + 1}/{max_retries})"
+                        )
+                        time.sleep(wait_time)
+                        continue
+
+                print(
+                    f"Error putting to {endpoint}: HTTP {status_code}",
+                    file=sys.stderr,
+                )
+                if e.response:
+                    try:
+                        error_detail = e.response.json()
+                        print(f"  Detail: {error_detail}", file=sys.stderr)
+                    except Exception:
+                        print(
+                            f"  Response: {e.response.text[:200]}",
+                            file=sys.stderr,
+                        )
+                return {}
+            except requests.exceptions.Timeout:
+                if attempt < max_retries - 1:
+                    wait_time = retry_delay * (2**attempt)
+                    print(
+                        f"  Request timeout, retrying in {wait_time}s... "
+                        f"(attempt {attempt + 1}/{max_retries})"
+                    )
+                    time.sleep(wait_time)
+                    continue
+                print(f"Error: Request timeout on {endpoint}", file=sys.stderr)
+                return {}
+            except requests.exceptions.RequestException as e:
+                print(f"Error putting to {endpoint}: {e}", file=sys.stderr)
+                return {}
+
+        return {}
+
 
 def ask_confirmation(prompt: str) -> bool:
     """