Commit 0f727da4447b
Changed files (4)
tools
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:
"""