Commit ffeb6c078c89

Vincent Demeester <vincent@sbr.pm>
2025-12-19 00:19:21
feat(arr): add lidarr path management and duplicate segment fixing
- Resolve artist folder corruption from duplicate path segments - Improve Spotify sync reliability with better path validation - Enable automated cleanup of malformed artist paths Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 942b578
tools/arr/commands/lidarr_fix_duplicate_paths.py
@@ -0,0 +1,145 @@
+"""
+Fix duplicate path segments in Lidarr artist paths.
+
+This script:
+1. Fetches all artists from Lidarr API
+2. Detects duplicate path segments (e.g., /music/library/library/Artist)
+3. Fixes them to remove the duplicate (e.g., /music/library/Artist)
+4. Updates the artist paths via PUT /api/v1/artist/{id}
+"""
+
+
+from lib import ArrClient, CommandContext, print_section_header
+
+
+def strip_duplicate_path_segments(path: str) -> tuple[str, bool, str]:
+    """
+    Detect and strip duplicate path segments.
+
+    For example: /neo/music/library/library -> /neo/music/library
+
+    Returns:
+        Tuple of (cleaned_path, was_modified, duplicate_segment)
+    """
+    parts = [p for p in path.split('/') if p]  # Split and remove empty parts
+
+    # Check for any duplicate consecutive segments
+    for i in range(len(parts) - 1):
+        if parts[i] == parts[i + 1]:
+            # Found duplicate - remove it
+            cleaned_parts = parts[:i + 1] + parts[i + 2:]
+            cleaned = '/' + '/'.join(cleaned_parts)
+            return (cleaned, True, parts[i])
+
+    return (path, False, "")
+
+
+def run(url: str, api_key: str, dry_run: bool, no_confirm: bool):
+    """Execute the lidarr fix-duplicate-paths command."""
+    # Create client and context
+    lidarr = ArrClient(url, api_key)
+    ctx = CommandContext(dry_run, no_confirm)
+
+    print_section_header("FETCHING ARTISTS FROM LIDARR")
+    print("Fetching all artists...")
+    artists = lidarr.get("/api/v1/artist")
+    print(f"Found {len(artists)} artists\n")
+
+    # Analyze all artists for duplicate path segments
+    needs_fixing = []
+    already_correct = []
+
+    for artist in artists:
+        artist_name = artist.get("artistName", "Unknown")
+        artist_id = artist.get("id")
+        current_path = artist.get("path", "")
+
+        cleaned_path, has_duplicate, duplicate_segment = strip_duplicate_path_segments(current_path)
+
+        if has_duplicate:
+            needs_fixing.append({
+                "id": artist_id,
+                "name": artist_name,
+                "old_path": current_path,
+                "new_path": cleaned_path,
+                "duplicate": duplicate_segment,
+            })
+        else:
+            already_correct.append(artist_name)
+
+    # Print summary
+    print_section_header("SUMMARY")
+
+    if already_correct:
+        print(f"\n✓ Already correct ({len(already_correct)} artists)")
+        if len(already_correct) <= 10:
+            for name in already_correct:
+                print(f"  - {name}")
+        else:
+            for name in already_correct[:5]:
+                print(f"  - {name}")
+            print(f"  ... and {len(already_correct) - 5} more")
+
+    if needs_fixing:
+        print(f"\n→ Needs fixing ({len(needs_fixing)} artists):\n")
+        for artist_info in needs_fixing:
+            print(f"  • {artist_info['name']}")
+            print(f"    Duplicate segment: '{artist_info['duplicate']}'")
+            print(f"    FROM: {artist_info['old_path']}")
+            print(f"    TO:   {artist_info['new_path']}")
+            print()
+    else:
+        print("\n✓ No artists with duplicate path segments found!")
+        return
+
+    # Perform fixes
+    if not ctx.dry_run:
+        # Ask for confirmation
+        if not ctx.no_confirm:
+            response = input(f"\nFix {len(needs_fixing)} artist path(s)? (y/n): ").lower().strip()
+            if response not in ["y", "yes"]:
+                print("Operation cancelled")
+                return
+
+        print_section_header("FIXING PATHS")
+
+        success_count = 0
+        fail_count = 0
+
+        for artist_info in needs_fixing:
+            artist_id = artist_info["id"]
+            artist_name = artist_info["name"]
+            new_path = artist_info["new_path"]
+
+            print(f"\nFixing {artist_name}...", end=" ")
+
+            # Fetch current artist data
+            artist_data = lidarr.get(f"/api/v1/artist/{artist_id}")
+            if not artist_data:
+                print("✗ FAILED (could not fetch artist data)")
+                fail_count += 1
+                continue
+
+            # Update the path
+            artist_data["path"] = new_path
+
+            # Send the update
+            result = lidarr.put(f"/api/v1/artist/{artist_id}", artist_data)
+            if result:
+                print("✓ SUCCESS")
+                success_count += 1
+            else:
+                print("✗ FAILED")
+                fail_count += 1
+
+        print_section_header("FINAL SUMMARY")
+        print(f"\nTotal artists processed: {len(needs_fixing)}")
+        print(f"  - Successfully fixed: {success_count}")
+        print(f"  - Failed: {fail_count}")
+
+        if success_count > 0:
+            print("\n✓ Path fixes have been applied!")
+            print("Note: You may need to trigger a 'Rescan Artist Folder' in Lidarr")
+            print("      for the changes to take full effect.")
+    else:
+        print("\n[DRY RUN] No changes were made. Remove --dry-run to apply fixes.")
tools/arr/commands/lidarr_sync_spotify.py
@@ -12,6 +12,8 @@ This script:
 import time
 from typing import Any, Dict, List, Set
 
+import requests
+
 from lib import (
     ArrClient,
     CommandContext,
@@ -39,17 +41,451 @@ def get_metadata_profile_id(client: ArrClient) -> int:
     return 1  # Default fallback
 
 
-def search_artist_in_lidarr(
-    client: ArrClient, artist_name: str
+def strip_duplicate_path_segments(path: str) -> tuple[str, bool, str]:
+    """
+    Detect and strip duplicate path segments.
+
+    For example: /neo/music/library/library -> /neo/music/library
+
+    Returns:
+        Tuple of (cleaned_path, was_modified, duplicate_segment)
+    """
+    parts = [p for p in path.split('/') if p]  # Split and remove empty parts
+
+    # Check for any duplicate consecutive segments
+    for i in range(len(parts) - 1):
+        if parts[i] == parts[i + 1]:
+            # Found duplicate - remove it
+            cleaned_parts = parts[:i + 1] + parts[i + 2:]
+            cleaned = '/' + '/'.join(cleaned_parts)
+            return (cleaned, True, parts[i])
+
+    return (path, False, "")
+
+
+def get_root_folder_path(client: ArrClient, preferred_path: str = None) -> str:
+    """
+    Get the appropriate root folder path from Lidarr.
+
+    Args:
+        client: Lidarr API client
+        preferred_path: Optional preferred root folder path
+
+    Returns:
+        Root folder path to use
+    """
+    folders = client.get("/api/v1/rootfolder")
+    if not folders or len(folders) == 0:
+        return "/music"  # Default fallback
+
+    # If user specified a preferred path, try to use it
+    if preferred_path:
+        for folder in folders:
+            if folder.get("path") == preferred_path:
+                return preferred_path
+
+    # Otherwise, use the first one
+    # Show all available folders for user awareness
+    print("Available root folders:")
+    for idx, folder in enumerate(folders):
+        marker = " (using)" if idx == 0 else ""
+        print(f"  [{idx+1}] {folder.get('path')}{marker}")
+
+    selected_path = folders[0].get("path")
+
+    # Check for duplicate path segments and fix if found
+    cleaned_path, was_modified, duplicate_seg = strip_duplicate_path_segments(selected_path)
+    if was_modified:
+        print(f"\nWARNING: Root folder has duplicate path segment '{duplicate_seg}'!")
+        print(f"  Original: {selected_path}")
+        print(f"  Using:    {cleaned_path}")
+        print("  Consider fixing the root folder configuration in Lidarr settings.\n")
+        return cleaned_path
+
+    return selected_path
+
+
+def search_musicbrainz_artist(artist_name: str, debug: bool = False) -> Dict[str, Any] | None:
+    """
+    Search MusicBrainz directly for an artist.
+
+    Returns artist data compatible with Lidarr's format, or None if not found.
+
+    Note: MusicBrainz rate limit is 1 request/second, enforced with sleep.
+    """
+    def query_mb(search_term: str, require_exact: bool = False):
+        """Helper to query MusicBrainz with a specific search term."""
+        url = "https://musicbrainz.org/ws/2/artist"
+        headers = {
+            "User-Agent": "LidarrSpotifySync/1.0 (https://github.com/yourusername/yourrepo)",
+            "Accept": "application/json",
+        }
+        params = {
+            "query": f'artist:"{search_term}"',
+            "fmt": "json",
+            "limit": 5,
+        }
+
+        # MusicBrainz rate limit: 1 request per second
+        time.sleep(1.0)
+
+        response = requests.get(url, headers=headers, params=params, timeout=10)
+        response.raise_for_status()
+        data = response.json()
+
+        artists = data.get("artists", [])
+        if not artists:
+            return None
+
+        # Filter results
+        for artist in artists:
+            mb_name = artist.get("name", "")
+
+            # For exact match requirement, compare case-insensitive but preserve accents
+            if require_exact:
+                if mb_name.lower() == search_term.lower():
+                    return artist
+            else:
+                # For normalized match, use our matching function
+                if artist_name_matches(mb_name, artist_name):
+                    return artist
+
+        return None
+
+    try:
+        # First try: exact match with original name (preserves accents)
+        if debug:
+            print(f"  DEBUG: Querying MusicBrainz for exact match: '{artist_name}'")
+
+        result = query_mb(artist_name, require_exact=True)
+        if result:
+            mb_name = result.get("name", "")
+            if debug:
+                print(f"  DEBUG: Found exact match in MusicBrainz: '{mb_name}'")
+        else:
+            # Second try: normalized version (without accents)
+            normalized = normalize_artist_name(artist_name)
+            if normalized != artist_name:
+                if debug:
+                    print(f"  DEBUG: No exact match, trying normalized: '{normalized}'")
+                result = query_mb(normalized, require_exact=False)
+                if result:
+                    mb_name = result.get("name", "")
+                    if debug:
+                        print(f"  DEBUG: Found normalized match in MusicBrainz: '{mb_name}'")
+
+        if not result:
+            if debug:
+                print("  DEBUG: MusicBrainz found no matching results")
+            return None
+
+        # Convert MusicBrainz data to Lidarr format
+        mb_id = result.get("id")
+        mb_name = result.get("name", "")
+        artist_type = result.get("type", "").capitalize()
+
+        # Map MusicBrainz types to Lidarr types
+        if artist_type == "Person":
+            artist_type = "Person"
+        elif artist_type == "Group":
+            artist_type = "Group"
+        else:
+            artist_type = "Person"  # Default
+
+        return {
+            "artistName": mb_name,
+            "foreignArtistId": mb_id,
+            "artistType": artist_type,
+            "disambiguation": result.get("disambiguation", ""),
+            "links": [],
+            "images": [],
+            "genres": [],
+            "tags": [],
+        }
+
+    except requests.exceptions.RequestException as e:
+        if debug:
+            print(f"  DEBUG: MusicBrainz query failed: {e}")
+        return None
+    except Exception as e:
+        if debug:
+            print(f"  DEBUG: Error parsing MusicBrainz response: {e}")
+        return None
+
+
+def normalize_artist_name(name: str) -> str:
+    """
+    Normalize artist name for better search matching.
+
+    Removes accents, special characters, and common variations.
+    """
+    import unicodedata
+
+    normalized = name
+
+    # Normalize different types of hyphens/dashes to regular hyphen
+    # U+2010 (HYPHEN), U+2011 (NON-BREAKING HYPHEN), U+2012 (FIGURE DASH),
+    # U+2013 (EN DASH), U+2014 (EM DASH), U+2015 (HORIZONTAL BAR)
+    hyphen_chars = ['\u2010', '\u2011', '\u2012', '\u2013', '\u2014', '\u2015']
+    for hyphen in hyphen_chars:
+        normalized = normalized.replace(hyphen, '-')
+
+    # Normalize different types of apostrophes/quotes to regular apostrophe
+    # U+2019 (RIGHT SINGLE QUOTATION MARK), U+02BC (MODIFIER LETTER APOSTROPHE)
+    # U+2018 (LEFT SINGLE QUOTATION MARK), U+201B (SINGLE HIGH-REVERSED-9 QUOTATION MARK)
+    apostrophe_chars = ['\u2019', '\u02BC', '\u2018', '\u201B']
+    for apostrophe in apostrophe_chars:
+        normalized = normalized.replace(apostrophe, "'")
+
+    # Remove unicode accents
+    normalized = unicodedata.normalize('NFD', normalized)
+    normalized = ''.join(char for char in normalized if unicodedata.category(char) != 'Mn')
+
+    # Common replacements
+    normalized = normalized.replace('&', 'and')
+    normalized = normalized.replace('/', ' ')
+
+    return normalized
+
+
+def artist_name_matches(result_name: str, search_name: str) -> bool:
+    """
+    Check if a result artist name matches the search name.
+
+    Returns True if they match exactly or very closely (normalized).
+    """
+    # Normalize both names for comparison
+    norm_result = normalize_artist_name(result_name).lower().strip()
+    norm_search = normalize_artist_name(search_name).lower().strip()
+
+    # Exact match
+    if norm_result == norm_search:
+        return True
+
+    # Match without "The"
+    if norm_result.startswith("the "):
+        norm_result = norm_result[4:]
+    if norm_search.startswith("the "):
+        norm_search = norm_search[4:]
+
+    return norm_result == norm_search
+
+
+def filter_search_results(
+    results: List[Dict[str, Any]], artist_name: str, debug: bool = False
 ) -> List[Dict[str, Any]]:
-    """Search for an artist in Lidarr's database."""
-    return client.get("/api/v1/search", params={"term": artist_name})
+    """
+    Filter search results to find artists that match the search name.
+
+    Search results can contain both artists and albums. We need to:
+    1. Extract the artist from each result
+    2. Check if the artist name matches
+    3. Return only matching results
+    """
+    filtered = []
+
+    for result in results:
+        # Extract artist from result (could be direct artist or nested in album)
+        result_artist = None
+        if "artist" in result:
+            result_artist = result["artist"]
+        elif "album" in result and isinstance(result["album"], dict):
+            result_artist = result["album"].get("artist")
+        elif "artistName" in result:
+            result_artist = result
+
+        if result_artist:
+            result_artist_name = result_artist.get("artistName", "")
+            if artist_name_matches(result_artist_name, artist_name):
+                filtered.append(result)
+                if debug:
+                    print(f"  DEBUG: Matched '{result_artist_name}' to '{artist_name}'")
+            elif debug:
+                print(f"  DEBUG: Rejected '{result_artist_name}' (doesn't match '{artist_name}')")
+
+    return filtered
 
 
-def get_existing_artists(client: ArrClient) -> Set[str]:
-    """Get set of artist names already in Lidarr."""
+def search_artist_in_lidarr(
+    client: ArrClient, artist_name: str, debug: bool = False
+) -> List[Dict[str, Any]]:
+    """
+    Search for an artist in Lidarr's database with fallback strategies.
+
+    Tries multiple search approaches:
+    1. Exact name from Spotify
+    2. Normalized name (without accents/special chars)
+    3. Name without leading "The"
+    4. First word only (for multi-word names)
+
+    Always filters results to find best match.
+    """
+    # Try exact search first
+    results = client.get("/api/v1/search", params={"term": artist_name})
+    if debug and not results:
+        print(f"  DEBUG: No results for exact search: '{artist_name}'")
+    if results:
+        if debug:
+            print(f"  DEBUG: Found {len(results)} results for '{artist_name}'")
+        # Filter to find exact or close matches
+        filtered = filter_search_results(results, artist_name, debug)
+        if filtered:
+            return filtered
+        if debug:
+            print("  DEBUG: No exact match in results, trying fallbacks")
+
+    # Try normalized version (without accents, & -> and, etc.)
+    normalized = normalize_artist_name(artist_name)
+    if normalized != artist_name:
+        results = client.get("/api/v1/search", params={"term": normalized})
+        if debug and not results:
+            print(f"  DEBUG: No results for normalized: '{normalized}'")
+        if results:
+            filtered = filter_search_results(results, artist_name, debug)
+            if filtered:
+                print(f"  Found using normalized name: '{normalized}'")
+                return filtered
+
+    # Try without leading "The"
+    if artist_name.lower().startswith("the "):
+        without_the = artist_name[4:]
+        results = client.get("/api/v1/search", params={"term": without_the})
+        if debug and not results:
+            print(f"  DEBUG: No results without 'The': '{without_the}'")
+        if results:
+            filtered = filter_search_results(results, artist_name, debug)
+            if filtered:
+                print(f"  Found without 'The': '{without_the}'")
+                return filtered
+
+    # Last resort: Query MusicBrainz directly
+    if debug:
+        print("  DEBUG: Lidarr search failed, trying MusicBrainz directly")
+
+    mb_artist = search_musicbrainz_artist(artist_name, debug=debug)
+    if mb_artist:
+        print(f"  Found in MusicBrainz: '{mb_artist['artistName']}'")
+        # Return in the same format as Lidarr search results
+        # Wrap in a result structure similar to what Lidarr returns
+        return [{"artist": mb_artist}]
+
+    if debug:
+        print(f"  DEBUG: All search strategies failed for '{artist_name}'")
+    return []
+
+
+def monitor_artist_albums(
+    client: ArrClient,
+    artist_id: int,
+    playlist_album_names: Set[str],
+    search_albums: bool = False,
+    debug: bool = False,
+) -> tuple[int, int]:
+    """
+    Monitor specific albums for an artist in Lidarr.
+
+    Args:
+        client: Lidarr API client
+        artist_id: Lidarr artist ID
+        playlist_album_names: Set of album names from playlists
+        search_albums: Whether to trigger album search after monitoring
+        debug: Enable debug output
+
+    Returns:
+        Tuple of (matched_count, monitored_count)
+    """
+    # Get artist with albums
+    artist = client.get(f"/api/v1/artist/{artist_id}")
+    if not artist:
+        if debug:
+            print(f"    DEBUG: Could not fetch artist {artist_id}")
+        return (0, 0)
+
+    artist_name = artist.get("artistName", "Unknown")
+    albums = artist.get("albums", [])
+
+    # Debug: show artist stats
+    if debug:
+        monitored = artist.get("monitored", False)
+        statistics = artist.get("statistics", {})
+        album_count = statistics.get("albumCount", 0)
+        print(f"    DEBUG: Artist monitored={monitored}, albumCount={album_count}, albums in response={len(albums)}")
+
+    if not albums:
+        if debug:
+            print(f"    DEBUG: No albums found for {artist_name}")
+        return (0, 0)
+
+    matched_count = 0
+    monitored_count = 0
+
+    # Match playlist albums to Lidarr albums
+    for album in albums:
+        album_title = album.get("title", "")
+        album_id = album.get("id")
+        already_monitored = album.get("monitored", False)
+
+        # Check if this album matches any in the playlists
+        for playlist_album in playlist_album_names:
+            # Normalize both for comparison
+            if normalize_artist_name(album_title).lower() == normalize_artist_name(playlist_album).lower():
+                matched_count += 1
+
+                if not already_monitored:
+                    if debug:
+                        print(f"    Monitoring album: {album_title}")
+
+                    # Update album to monitored
+                    album["monitored"] = True
+                    try:
+                        client.put(f"/api/v1/album/{album_id}", album)
+                        monitored_count += 1
+
+                        # Optionally trigger search
+                        if search_albums:
+                            client.post("/api/v1/command", {
+                                "name": "AlbumSearch",
+                                "albumIds": [album_id]
+                            })
+                    except Exception as e:
+                        if debug:
+                            print(f"    DEBUG: Failed to monitor album {album_title}: {e}")
+                else:
+                    if debug:
+                        print(f"    Album already monitored: {album_title}")
+
+                break  # Found match, move to next album
+
+    return (matched_count, monitored_count)
+
+
+def get_existing_artists(client: ArrClient) -> tuple[Set[str], Set[str], Dict[str, int]]:
+    """
+    Get set of artist names and foreign IDs already in Lidarr.
+
+    Returns:
+        Tuple of (normalized artist names set, foreign artist IDs set, foreign_id -> lidarr_id mapping)
+    """
     artists = client.get("/api/v1/artist")
-    return {artist.get("artistName", "").lower() for artist in artists}
+    names = set()
+    foreign_ids = set()
+    foreign_to_lidarr_id = {}
+
+    for artist in artists:
+        # Store normalized name
+        name = artist.get("artistName", "")
+        if name:
+            names.add(normalize_artist_name(name).lower())
+
+        # Store foreign artist ID (MusicBrainz ID)
+        foreign_id = artist.get("foreignArtistId")
+        lidarr_id = artist.get("id")
+        if foreign_id:
+            foreign_ids.add(foreign_id)
+            if lidarr_id:
+                foreign_to_lidarr_id[foreign_id] = lidarr_id
+
+    return names, foreign_ids, foreign_to_lidarr_id
 
 
 def add_artist_to_lidarr(
@@ -59,6 +495,7 @@ def add_artist_to_lidarr(
     quality_profile_id: int,
     metadata_profile_id: int,
     monitor: str = "all",
+    debug: bool = False,
 ) -> Dict[str, Any]:
     """
     Add an artist to Lidarr.
@@ -70,10 +507,29 @@ def add_artist_to_lidarr(
         quality_profile_id: Quality profile ID
         metadata_profile_id: Metadata profile ID
         monitor: Monitoring option (all, future, missing, existing, none)
+        debug: Enable debug output
 
     Returns:
         API response
     """
+    # Check for suspicious fields in artist data
+    if debug:
+        suspicious_fields = ["folder", "path", "rootFolderPath"]
+        found_suspicious = {k: artist.get(k) for k in suspicious_fields if k in artist}
+        if found_suspicious:
+            print(f"  DEBUG: Found fields in artist data: {found_suspicious}")
+
+    # CRITICAL FIX: Remove the folder field from artist data before building payload
+    # The folder field contains paths like 'library/ArtistName' which causes
+    # Lidarr to append it to rootFolderPath, creating duplicates like:
+    # /neo/music/library + library/Artist = /neo/music/library/library/Artist
+    artist = dict(artist)  # Make a copy to avoid modifying the original
+    artist.pop("folder", None)
+    artist.pop("path", None)
+    artist.pop("rootFolderPath", None)
+
+    # Build payload with only writable fields from search result
+    # IMPORTANT: Do not include 'folder' or 'path' fields as they can cause duplicate paths
     payload = {
         "artistName": artist.get("artistName"),
         "foreignArtistId": artist.get("foreignArtistId"),
@@ -81,11 +537,146 @@ def add_artist_to_lidarr(
         "metadataProfileId": metadata_profile_id,
         "rootFolderPath": root_folder,
         "monitored": True,
-        "addOptions": {"monitor": monitor, "searchForMissingAlbums": False},
+        "albumFolder": True,
+        "monitorNewItems": "all",
+        # Include metadata from search
+        "artistType": artist.get("artistType", ""),
+        "disambiguation": artist.get("disambiguation", ""),
+        "links": artist.get("links", []),
+        "images": artist.get("images", []),
+        "genres": artist.get("genres", []),
+        "tags": artist.get("tags", []),
+        # Add options
+        "addOptions": {
+            "monitor": monitor,
+            "searchForMissingAlbums": False,
+        },
     }
+
+    if debug:
+        print(f"  DEBUG: Sending rootFolderPath={root_folder}")
+        # Verify no folder/path in final payload
+        if "folder" in payload or "path" in payload:
+            print("  ERROR: folder or path still in payload!")
+        else:
+            print("  DEBUG: Confirmed no folder/path in payload")
+
+        # Show full payload for debugging
+        print(f"  DEBUG: Full payload keys: {list(payload.keys())}")
+
     return client.post("/api/v1/artist", payload)
 
 
+def try_add_single_artist(
+    lidarr: ArrClient,
+    artist_name: str,
+    artist_data: Dict[str, Any],
+    root_folder: str,
+    quality_profile_id: int,
+    metadata_profile_id: int,
+    monitor: str,
+    ctx: CommandContext,
+    existing_foreign_ids: Set[str] = None,
+    debug: bool = False,
+) -> tuple[bool, str, Dict[str, Any] | None]:
+    """
+    Attempt to add a single artist to Lidarr.
+
+    Args:
+        lidarr: Lidarr API client
+        artist_name: Artist name from Spotify
+        artist_data: Artist data including albums
+        root_folder: Root folder path
+        quality_profile_id: Quality profile ID
+        metadata_profile_id: Metadata profile ID
+        monitor: Monitor mode
+        ctx: Command context
+        existing_foreign_ids: Set of foreign artist IDs already in Lidarr
+
+    Returns:
+        Tuple of (success: bool, error_message: str, lidarr_name: str | None)
+    """
+    if existing_foreign_ids is None:
+        existing_foreign_ids = set()
+
+    try:
+        # Search for artist in Lidarr's MusicBrainz database
+        search_results = search_artist_in_lidarr(lidarr, artist_name, debug=debug)
+
+        if not search_results:
+            return (False, "Not found in MusicBrainz", None)
+
+        # Search returns both artists and albums - extract artist from first result
+        first_result = search_results[0]
+
+        # If result has 'artist' field, it's an album result - extract the artist
+        if "artist" in first_result:
+            artist_match = first_result["artist"]
+        # If result has 'album' field with nested artist
+        elif "album" in first_result and isinstance(first_result["album"], dict):
+            album = first_result["album"]
+            if "artist" in album:
+                artist_match = album["artist"]
+            else:
+                return (False, f"Album result has no artist field: {list(album.keys())}", None)
+        # If result has 'artistName', it's already an artist result
+        elif "artistName" in first_result:
+            artist_match = first_result
+        else:
+            # Unknown result type
+            return (False, f"Unexpected search result format: {list(first_result.keys())}", None)
+
+        artist_mb_name = artist_match.get("artistName", artist_name)
+        foreign_artist_id = artist_match.get("foreignArtistId")
+
+        # Check if artist is already in Lidarr by foreign ID
+        if foreign_artist_id and foreign_artist_id in existing_foreign_ids:
+            print(f"  Found: {artist_mb_name}")
+            print("  Already in Lidarr (detected by MusicBrainz ID)")
+            return (False, "Already exists in Lidarr", artist_mb_name)
+
+        print(f"  Found: {artist_mb_name}")
+        print(f"  Albums in playlists: {len(artist_data['albums'])}")
+        for album in list(artist_data["albums"])[:3]:
+            print(f"    - {album}")
+        if len(artist_data["albums"]) > 3:
+            print(f"    ... and {len(artist_data['albums']) - 3} more albums")
+
+        if not ctx.dry_run:
+            result = add_artist_to_lidarr(
+                lidarr,
+                artist_match,
+                root_folder,
+                quality_profile_id,
+                metadata_profile_id,
+                monitor,
+                debug=debug,
+            )
+
+            if result and result.get("id"):
+                actual_path = result.get("path", "unknown")
+                print(f"  ✓ Added successfully (ID: {result['id']})")
+
+                if debug:
+                    print(f"  DEBUG: Lidarr assigned path: {actual_path}")
+
+                # Check if Lidarr created a duplicate path
+                if "library/library" in actual_path:
+                    print("  ⚠️  WARNING: Lidarr created duplicate path!")
+                    print(f"     We sent rootFolderPath={root_folder}")
+                    print(f"     Lidarr created: {actual_path}")
+
+                return (True, "", artist_mb_name)
+            else:
+                return (False, "Failed to add artist (no ID returned)", artist_mb_name)
+        else:
+            print("  [DRY RUN] Would add this artist")
+            return (True, "", artist_mb_name)
+
+    except Exception as e:
+        return (False, f"Error: {str(e)}", None)
+
+
 def run(
     lidarr_url: str,
     lidarr_api_key: str,
@@ -99,6 +690,7 @@ def run(
     dry_run: bool,
     no_confirm: bool,
     all_playlists: bool,
+    debug: bool = False,
 ):
     """Execute the lidarr sync-spotify command."""
     # Create clients and context
@@ -177,6 +769,36 @@ def run(
     quality_profile_id = get_quality_profile_id(lidarr)
     metadata_profile_id = get_metadata_profile_id(lidarr)
 
+    # Check Lidarr's naming configuration (only in debug mode)
+    if debug:
+        print("\nChecking Lidarr naming configuration...")
+        try:
+            naming_config = lidarr.get("/api/v1/config/naming")
+            artist_folder_format = naming_config.get("artistFolderFormat", "Not set")
+            print(f"  Artist Folder Format: {artist_folder_format}")
+        except Exception as e:
+            print(f"  Could not fetch naming config: {e}")
+
+    # Use specified root folder or auto-detect from Lidarr
+    if root_folder and root_folder != "/music":  # /music is the default from CLI
+        if debug:
+            print(f"\nUsing user-specified root folder: {root_folder}")
+    else:
+        root_folder = get_root_folder_path(lidarr)
+        if debug:
+            print(f"\nAuto-detected root folder from Lidarr: {root_folder}")
+
+    print("\nUsing Lidarr configuration:")
+    print(f"  Quality Profile ID: {quality_profile_id}")
+    print(f"  Metadata Profile ID: {metadata_profile_id}")
+    print(f"  Root Folder: {root_folder}")
+
+    # Verify no duplicate segments in root folder
+    _, has_duplicate, duplicate_seg = strip_duplicate_path_segments(root_folder)
+    if has_duplicate:
+        print(f"  WARNING: Root folder has duplicate segment '{duplicate_seg}'!")
+        print("  This should have been cleaned by get_root_folder_path()")
+
     # Track all unique artists and their albums
     all_artists = {}  # artist_name -> {spotify_id, albums: set()}
     playlist_info = []
@@ -224,15 +846,17 @@ def run(
     # Check which artists are already in Lidarr
     print_section_header("CHECKING LIDARR")
     print("Fetching existing artists from Lidarr...")
-    existing_artists = get_existing_artists(lidarr)
-    print(f"Found {len(existing_artists)} artists already in Lidarr")
+    existing_names, existing_foreign_ids, foreign_to_lidarr_id = get_existing_artists(lidarr)
+    print(f"Found {len(existing_names)} artists already in Lidarr")
 
     # Separate artists into existing and missing
     artists_to_add = []
     artists_already_in_lidarr = []
 
     for artist_name, artist_data in all_artists.items():
-        if artist_name.lower() in existing_artists:
+        # Check normalized name
+        normalized_name = normalize_artist_name(artist_name).lower()
+        if normalized_name in existing_names:
             artists_already_in_lidarr.append(artist_name)
         else:
             artists_to_add.append((artist_name, artist_data))
@@ -265,7 +889,7 @@ def run(
             print("Operation cancelled")
         return
 
-    # Add artists to Lidarr
+    # Add artists to Lidarr with retry mechanism
     print_section_header("ADDING ARTISTS TO LIDARR")
     print(
         f"Note: Adding {len(artists_to_add)} artists with delays "
@@ -273,75 +897,196 @@ def run(
     )
 
     added_count = 0
-    failed_count = 0
     dry_run_artists = []  # Track artists for dry-run output
+    failed_artists = []  # Track failed artists for retry
+    max_retries = 2
 
+    # First pass: try adding all artists
     for idx, (artist_name, artist_data) in enumerate(artists_to_add, 1):
         print(f"\n[{idx}/{len(artists_to_add)}] Searching for: {artist_name}")
 
-        try:
-            # Search for artist in Lidarr's MusicBrainz database
-            search_results = search_artist_in_lidarr(lidarr, artist_name)
+        success, error_msg, lidarr_name = try_add_single_artist(
+            lidarr,
+            artist_name,
+            artist_data,
+            root_folder,
+            quality_profile_id,
+            metadata_profile_id,
+            monitor,
+            ctx,
+            existing_foreign_ids,
+            debug,
+        )
 
-            if not search_results:
-                print(f"  ✗ No results found for {artist_name}")
-                failed_count += 1
-                # Small delay even on failure to avoid hammering the API
-                if idx < len(artists_to_add):
-                    time.sleep(0.5)
-                continue
-
-            # Use the first result (most relevant)
-            artist_match = search_results[0]
-            artist_mb_name = artist_match.get("artistName", artist_name)
-
-            print(f"  Found: {artist_mb_name}")
-            print(f"  Albums in playlists: {len(artist_data['albums'])}")
-            for album in list(artist_data["albums"])[:3]:
-                print(f"    - {album}")
-            if len(artist_data["albums"]) > 3:
-                print(
-                    f"    ... and {len(artist_data['albums']) - 3} more albums"
-                )
-
-            if not ctx.dry_run:
-                result = add_artist_to_lidarr(
-                    lidarr,
-                    artist_match,
-                    root_folder,
-                    quality_profile_id,
-                    metadata_profile_id,
-                    monitor,
-                )
-
-                if result and result.get("id"):
-                    print(f"  ✓ Added successfully (ID: {result['id']})")
-                    added_count += 1
-                else:
-                    print("  ✗ Failed to add artist")
-                    failed_count += 1
-            else:
-                print("  [DRY RUN] Would add this artist")
+        if success:
+            if ctx.dry_run:
                 dry_run_artists.append(
                     {
                         "spotify_name": artist_name,
-                        "lidarr_name": artist_mb_name,
+                        "lidarr_name": lidarr_name or artist_name,
                         "albums": artist_data["albums"],
                     }
                 )
+            added_count += 1
+        else:
+            print(f"  ✗ {error_msg}")
+            failed_artists.append((artist_name, artist_data, error_msg))
+
+        # Add delay between requests
+        if idx < len(artists_to_add):
+            delay = request_delay if not ctx.dry_run else 0.5
+            time.sleep(delay)
+
+    # Retry failed artists (up to max_retries times)
+    retry_round = 1
+    while failed_artists and retry_round <= max_retries and not ctx.dry_run:
+        print_section_header(f"RETRY ROUND {retry_round}")
+        print(f"Retrying {len(failed_artists)} failed artist(s)...")
+
+        still_failed = []
+
+        for idx, (artist_name, artist_data, prev_error) in enumerate(
+            failed_artists, 1
+        ):
+            print(
+                f"\n[Retry {retry_round}/{max_retries}] "
+                f"[{idx}/{len(failed_artists)}] {artist_name}"
+            )
+            print(f"  Previous error: {prev_error}")
+
+            success, error_msg, lidarr_name = try_add_single_artist(
+                lidarr,
+                artist_name,
+                artist_data,
+                root_folder,
+                quality_profile_id,
+                metadata_profile_id,
+                monitor,
+                ctx,
+                existing_foreign_ids,
+                debug,
+            )
+
+            if success:
                 added_count += 1
+                print("  ✓ Succeeded on retry!")
+            else:
+                print(f"  ✗ Still failing: {error_msg}")
+                still_failed.append((artist_name, artist_data, error_msg))
 
-            # Add a delay between requests to avoid overwhelming Lidarr
-            if idx < len(artists_to_add):
-                delay = request_delay if not ctx.dry_run else 0.5
-                time.sleep(delay)
+            # Add delay between retry requests
+            if idx < len(failed_artists):
+                time.sleep(request_delay)
 
-        except Exception as e:
-            print(f"  ✗ Error: {e}")
-            failed_count += 1
-            # Small delay even on error
-            if idx < len(artists_to_add):
-                time.sleep(0.5)
+        failed_artists = still_failed
+        retry_round += 1
+
+    failed_count = len(failed_artists)
+
+    # Monitor albums from playlists for ALL artists in Lidarr
+    if not ctx.dry_run:
+        print_section_header("MONITORING PLAYLIST ALBUMS")
+        print("Checking which albums from playlists should be monitored...")
+        print("\nNote: Newly added artists may take a few moments for Lidarr to")
+        print("      fetch their discography from MusicBrainz. If albums are")
+        print("      missing, try running the sync again in a minute or trigger")
+        print("      a 'Refresh Artist' in Lidarr's UI.\n")
+
+        # Collect artists to monitor (newly added + already existing)
+        artists_to_monitor = []
+
+        # Add newly added artists (need to fetch their IDs)
+        # For now, we'll fetch all artists again to get IDs
+        print("Fetching updated artist list...")
+        _, _, updated_foreign_to_lidarr_id = get_existing_artists(lidarr)
+
+        # Process all artists that have albums in playlists
+        for artist_name, artist_data in all_artists.items():
+            if not artist_data.get("albums"):
+                continue
+
+            # Try to find this artist in Lidarr by checking if we have their foreign ID
+            # We need to search for them to get the foreign ID
+            search_results = search_artist_in_lidarr(lidarr, artist_name, debug=False)
+            if not search_results:
+                continue
+
+            # Extract artist from search result
+            first_result = search_results[0]
+            artist_match = None
+            if "artist" in first_result:
+                artist_match = first_result["artist"]
+            elif "album" in first_result and isinstance(first_result["album"], dict):
+                artist_match = first_result["album"].get("artist")
+            elif "artistName" in first_result:
+                artist_match = first_result
+
+            if not artist_match:
+                continue
+
+            foreign_id = artist_match.get("foreignArtistId")
+            if foreign_id and foreign_id in updated_foreign_to_lidarr_id:
+                lidarr_id = updated_foreign_to_lidarr_id[foreign_id]
+                artists_to_monitor.append((artist_name, lidarr_id, artist_data["albums"]))
+
+        if not artists_to_monitor:
+            print("No artists with playlist albums found in Lidarr")
+        else:
+            print(f"Processing {len(artists_to_monitor)} artists...")
+            total_matched = 0
+            total_monitored = 0
+            artists_needing_refresh = []
+
+            for artist_name, lidarr_id, playlist_albums in artists_to_monitor:
+                print(f"\n  {artist_name}:")
+                matched, monitored = monitor_artist_albums(
+                    lidarr,
+                    lidarr_id,
+                    playlist_albums,
+                    search_albums=False,  # Don't auto-search for now
+                    debug=debug
+                )
+                total_matched += matched
+                total_monitored += monitored
+
+                if matched == 0:
+                    # Check if artist has no albums at all (might need refresh)
+                    artist_data = lidarr.get(f"/api/v1/artist/{lidarr_id}")
+                    if artist_data and not artist_data.get("albums"):
+                        artists_needing_refresh.append((artist_name, lidarr_id))
+                        print("    No albums found - may need refresh")
+                    else:
+                        print("    No matching albums found in Lidarr")
+                elif monitored == 0:
+                    print(f"    {matched} album(s) already monitored")
+
+            print(f"\n  Total albums matched: {total_matched}")
+            print(f"  Total albums newly monitored: {total_monitored}")
+
+            # Offer to refresh artists with no albums
+            if artists_needing_refresh:
+                print(f"\n  {len(artists_needing_refresh)} artist(s) have no albums and may need refresh:")
+                for name, _ in artists_needing_refresh[:5]:
+                    print(f"    - {name}")
+                if len(artists_needing_refresh) > 5:
+                    print(f"    ... and {len(artists_needing_refresh) - 5} more")
+
+                if not ctx.no_confirm:
+                    response = input("\n  Trigger refresh for these artists? (y/n): ").lower().strip()
+                    if response in ["y", "yes"]:
+                        print("\n  Triggering refresh...")
+                        for artist_name, artist_id in artists_needing_refresh:
+                            print(f"    Refreshing {artist_name}...", end=" ")
+                            try:
+                                lidarr.post("/api/v1/command", {
+                                    "name": "RefreshArtist",
+                                    "artistId": artist_id
+                                })
+                                print("✓")
+                            except Exception as e:
+                                print(f"✗ ({e})")
+                        print("\n  Refresh commands sent. Wait a moment and run sync again")
+                        print("  to monitor albums from playlists.")
 
     # Final summary
     print_section_header("FINAL SUMMARY")
@@ -350,7 +1095,27 @@ def run(
     print(f"Artists already in Lidarr: {len(artists_already_in_lidarr)}")
     print(f"Artists to add: {len(artists_to_add)}")
     print(f"  - Successfully added: {added_count}")
-    print(f"  - Failed: {failed_count}")
+    print(f"  - Failed after {max_retries} retries: {failed_count}")
+
+    # Show permanently failed artists with error details
+    if failed_artists and not ctx.dry_run:
+        print_section_header("PERMANENTLY FAILED ARTISTS")
+        print(
+            f"\nThe following {len(failed_artists)} artist(s) could not be "
+            f"added after {max_retries} retry attempts:\n"
+        )
+        for artist_name, artist_data, error_msg in failed_artists:
+            print(f"  • {artist_name}")
+            print(f"    Error: {error_msg}")
+            if artist_data.get("albums"):
+                album_count = len(artist_data["albums"])
+                print(f"    Albums in playlists: {album_count}")
+        print(
+            "\nTip: These failures may be due to:\n"
+            "  - Artist not found in MusicBrainz\n"
+            "  - Temporary API issues (try running again later)\n"
+            "  - Network connectivity problems"
+        )
 
     if ctx.dry_run:
         print(
tools/arr/arr
@@ -172,6 +172,50 @@ def lidarr_update_paths(url, api_key, music_folder, dry_run):
     lidarr_update_paths.run(url, api_key, music_folder, dry_run)
 
 
+@lidarr.command("fix-duplicate-paths")
+@click.argument("url")
+@click.option(
+    "--api-key",
+    "-k",
+    envvar="LIDARR_API_KEY",
+    required=True,
+    help="Lidarr API key",
+)
+@click.option(
+    "--dry-run",
+    is_flag=True,
+    help="Show what would be fixed without making changes",
+)
+@click.option(
+    "--no-confirm",
+    "--yolo",
+    is_flag=True,
+    help="Skip interactive confirmation (use with caution)",
+)
+def lidarr_fix_duplicate_paths(url, api_key, dry_run, no_confirm):
+    """Fix duplicate path segments in artist paths.
+
+    Detects and fixes paths like /music/library/library/Artist
+    to /music/library/Artist.
+
+    API key can be provided via --api-key flag or LIDARR_API_KEY environment variable.
+
+    Examples:
+        # With API key flag
+        arr lidarr fix-duplicate-paths http://localhost:8686 -k your-api-key
+
+        # With environment variable
+        export LIDARR_API_KEY=your-api-key
+        arr lidarr fix-duplicate-paths http://localhost:8686
+
+        # Dry run to preview changes
+        arr lidarr fix-duplicate-paths http://localhost:8686 -k your-api-key \\
+            --dry-run
+    """
+    from commands import lidarr_fix_duplicate_paths
+    lidarr_fix_duplicate_paths.run(url, api_key, dry_run, no_confirm)
+
+
 @lidarr.command("update-monitoring")
 @click.argument("url")
 @click.argument("api_key")
@@ -492,6 +536,11 @@ def lidarr_manage_queue(
     is_flag=True,
     help="Skip interactive confirmation (use with caution)",
 )
+@click.option(
+    "--debug",
+    is_flag=True,
+    help="Enable debug output",
+)
 def lidarr_sync_spotify(
     url,
     api_key,
@@ -505,6 +554,7 @@ def lidarr_sync_spotify(
     all_playlists,
     dry_run,
     no_confirm,
+    debug,
 ):
     """Sync Spotify playlists to Lidarr.
 
@@ -561,7 +611,8 @@ def lidarr_sync_spotify(
         request_delay,
         dry_run,
         no_confirm,
-        all_playlists
+        all_playlists,
+        debug
     )
 
 
tools/arr/fix-lidarr-naming.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+#     "requests>=2.31.0",
+# ]
+# ///
+"""
+Fix Lidarr naming configuration to remove duplicate 'library' folder.
+
+Changes artistFolderFormat from 'library/{Artist Name}' to '{Artist Name}'.
+"""
+
+import os
+import sys
+import requests
+
+# Get API key from environment
+api_key = os.environ.get("LIDARR_API_KEY")
+if not api_key:
+    print("Error: LIDARR_API_KEY environment variable not set")
+    sys.exit(1)
+
+base_url = "https://lidarr.sbr.pm"
+headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
+
+print("Fetching current naming configuration...")
+try:
+    response = requests.get(f"{base_url}/api/v1/config/naming", headers=headers)
+    response.raise_for_status()
+    config = response.json()
+except Exception as e:
+    print(f"Error fetching config: {e}")
+    sys.exit(1)
+
+print(f"\nCurrent Artist Folder Format: {config.get('artistFolderFormat')}")
+
+if config.get('artistFolderFormat') == 'library/{Artist Name}':
+    print("\nChanging to: {Artist Name}")
+
+    config['artistFolderFormat'] = '{Artist Name}'
+
+    try:
+        response = requests.put(f"{base_url}/api/v1/config/naming/{config['id']}",
+                               headers=headers, json=config)
+        response.raise_for_status()
+        print("✓ Successfully updated naming configuration!")
+        print("\nNew Artist Folder Format: {Artist Name}")
+        print("\nNote: This only affects NEW artists added to Lidarr.")
+        print("Existing artists with duplicate paths need to be fixed with:")
+        print("  arr lidarr fix-duplicate-paths https://lidarr.sbr.pm -k $LIDARR_API_KEY")
+    except Exception as e:
+        print(f"Error updating config: {e}")
+        sys.exit(1)
+elif config.get('artistFolderFormat') == '{Artist Name}':
+    print("✓ Already configured correctly!")
+else:
+    print(f"\nUnexpected format: {config.get('artistFolderFormat')}")
+    print("Please manually review this setting.")
tools/arr/lib.py
@@ -160,19 +160,32 @@ class ArrClient:
                         time.sleep(wait_time)
                         continue
 
-                print(
-                    f"Error posting to {endpoint}: HTTP {status_code}",
-                    file=sys.stderr,
-                )
-                if e.response:
+                # Better error reporting
+                if status_code:
+                    print(
+                        f"Error posting to {endpoint}: HTTP {status_code}",
+                        file=sys.stderr,
+                    )
+                else:
+                    print(
+                        f"Error posting to {endpoint}: {type(e).__name__} - {str(e)}",
+                        file=sys.stderr,
+                    )
+
+                # Print payload for debugging
+                print(f"  Payload: {payload}", file=sys.stderr)
+
+                # Always try to get response details
+                if e.response is not None:
+                    print(f"  Response status: {e.response.status_code}", file=sys.stderr)
+                    print(f"  Response headers: {dict(e.response.headers)}", file=sys.stderr)
                     try:
                         error_detail = e.response.json()
-                        print(f"  Detail: {error_detail}", file=sys.stderr)
+                        print(f"  Response JSON: {error_detail}", file=sys.stderr)
                     except Exception:
-                        print(
-                            f"  Response: {e.response.text[:200]}",
-                            file=sys.stderr,
-                        )
+                        print(f"  Response text: {e.response.text}", file=sys.stderr)
+                else:
+                    print("  No response object available", file=sys.stderr)
                 return {}
             except requests.exceptions.Timeout:
                 if attempt < max_retries - 1: