main
   1"""
   2Sync Spotify playlists to Lidarr.
   3
   4This script:
   51. Fetches tracks from specified Spotify playlists
   62. Extracts unique artists from the playlist tracks
   73. Checks which artists are already in Lidarr
   84. Adds missing artists to Lidarr with monitoring options
   95. Optionally monitors specific albums that appear in playlists
  10"""
  11
  12import time
  13from typing import Any, Dict, List, Set
  14
  15import requests
  16
  17from lib import (
  18    ArrClient,
  19    CommandContext,
  20    SpotifyClient,
  21    get_confirmation_decision,
  22    print_item_list,
  23    print_section_header,
  24    select_with_fzf,
  25)
  26
  27
  28def get_quality_profile_id(client: ArrClient) -> int:
  29    """Get the first available quality profile ID."""
  30    profiles = client.get("/api/v1/qualityprofile")
  31    if profiles and len(profiles) > 0:
  32        return profiles[0].get("id")
  33    return 1  # Default fallback
  34
  35
  36def get_metadata_profile_id(client: ArrClient) -> int:
  37    """Get the first available metadata profile ID."""
  38    profiles = client.get("/api/v1/metadataprofile")
  39    if profiles and len(profiles) > 0:
  40        return profiles[0].get("id")
  41    return 1  # Default fallback
  42
  43
  44def strip_duplicate_path_segments(path: str) -> tuple[str, bool, str]:
  45    """
  46    Detect and strip duplicate path segments.
  47
  48    For example: /neo/music/library/library -> /neo/music/library
  49
  50    Returns:
  51        Tuple of (cleaned_path, was_modified, duplicate_segment)
  52    """
  53    parts = [p for p in path.split("/") if p]  # Split and remove empty parts
  54
  55    # Check for any duplicate consecutive segments
  56    for i in range(len(parts) - 1):
  57        if parts[i] == parts[i + 1]:
  58            # Found duplicate - remove it
  59            cleaned_parts = parts[: i + 1] + parts[i + 2 :]
  60            cleaned = "/" + "/".join(cleaned_parts)
  61            return (cleaned, True, parts[i])
  62
  63    return (path, False, "")
  64
  65
  66def get_root_folder_path(client: ArrClient, preferred_path: str = None) -> str:
  67    """
  68    Get the appropriate root folder path from Lidarr.
  69
  70    Args:
  71        client: Lidarr API client
  72        preferred_path: Optional preferred root folder path
  73
  74    Returns:
  75        Root folder path to use
  76    """
  77    folders = client.get("/api/v1/rootfolder")
  78    if not folders or len(folders) == 0:
  79        return "/music"  # Default fallback
  80
  81    # If user specified a preferred path, try to use it
  82    if preferred_path:
  83        for folder in folders:
  84            if folder.get("path") == preferred_path:
  85                return preferred_path
  86
  87    # Otherwise, use the first one
  88    # Show all available folders for user awareness
  89    print("Available root folders:")
  90    for idx, folder in enumerate(folders):
  91        marker = " (using)" if idx == 0 else ""
  92        print(f"  [{idx + 1}] {folder.get('path')}{marker}")
  93
  94    selected_path = folders[0].get("path")
  95
  96    # Check for duplicate path segments and fix if found
  97    cleaned_path, was_modified, duplicate_seg = strip_duplicate_path_segments(
  98        selected_path
  99    )
 100    if was_modified:
 101        print(
 102            f"\nWARNING: Root folder has duplicate path segment '{duplicate_seg}'!"
 103        )
 104        print(f"  Original: {selected_path}")
 105        print(f"  Using:    {cleaned_path}")
 106        print(
 107            "  Consider fixing the root folder configuration in Lidarr settings.\n"
 108        )
 109        return cleaned_path
 110
 111    return selected_path
 112
 113
 114def search_musicbrainz_artist(
 115    artist_name: str, debug: bool = False
 116) -> Dict[str, Any] | None:
 117    """
 118    Search MusicBrainz directly for an artist.
 119
 120    Returns artist data compatible with Lidarr's format, or None if not found.
 121
 122    Note: MusicBrainz rate limit is 1 request/second, enforced with sleep.
 123    """
 124
 125    def query_mb(search_term: str, require_exact: bool = False):
 126        """Helper to query MusicBrainz with a specific search term."""
 127        url = "https://musicbrainz.org/ws/2/artist"
 128        headers = {
 129            "User-Agent": "LidarrSpotifySync/1.0 (https://github.com/yourusername/yourrepo)",
 130            "Accept": "application/json",
 131        }
 132        params = {
 133            "query": f'artist:"{search_term}"',
 134            "fmt": "json",
 135            "limit": 5,
 136        }
 137
 138        if debug:
 139            print(f"  DEBUG: MusicBrainz query: {params['query']}")
 140
 141        # MusicBrainz rate limit: 1 request per second
 142        time.sleep(1.0)
 143
 144        response = requests.get(
 145            url, headers=headers, params=params, timeout=10
 146        )
 147        response.raise_for_status()
 148        data = response.json()
 149
 150        artists = data.get("artists", [])
 151
 152        if debug:
 153            print(f"  DEBUG: MusicBrainz returned {len(artists)} results")
 154            if artists:
 155                for idx, artist in enumerate(artists[:3]):  # Show first 3
 156                    print(
 157                        f"    [{idx + 1}] {artist.get('name', 'N/A')} (ID: {artist.get('id', 'N/A')})"
 158                    )
 159                if len(artists) > 3:
 160                    print(f"    ... and {len(artists) - 3} more")
 161
 162        if not artists:
 163            return None
 164
 165        # Filter results
 166        for artist in artists:
 167            mb_name = artist.get("name", "")
 168
 169            # For exact match requirement, compare case-insensitive but preserve accents
 170            if require_exact:
 171                if mb_name.lower() == search_term.lower():
 172                    if debug:
 173                        print(f"  DEBUG: Exact match found: '{mb_name}'")
 174                    return artist
 175            else:
 176                # For normalized match, use our matching function
 177                if artist_name_matches(mb_name, artist_name):
 178                    if debug:
 179                        print(f"  DEBUG: Normalized match found: '{mb_name}'")
 180                    return artist
 181
 182        if debug:
 183            print(
 184                f"  DEBUG: No matching artist in results (require_exact={require_exact})"
 185            )
 186        return None
 187
 188    try:
 189        # First try: exact match with original name (preserves accents)
 190        if debug:
 191            print(
 192                f"  DEBUG: Querying MusicBrainz for exact match: '{artist_name}'"
 193            )
 194
 195        result = query_mb(artist_name, require_exact=True)
 196        if result:
 197            mb_name = result.get("name", "")
 198            if debug:
 199                print(
 200                    f"  DEBUG: Found exact match in MusicBrainz: '{mb_name}'"
 201                )
 202        else:
 203            # Second try: normalized version (without accents)
 204            normalized = normalize_artist_name(artist_name)
 205            if normalized != artist_name:
 206                if debug:
 207                    print(
 208                        f"  DEBUG: No exact match, trying normalized: '{normalized}'"
 209                    )
 210                result = query_mb(normalized, require_exact=False)
 211                if result:
 212                    mb_name = result.get("name", "")
 213                    if debug:
 214                        print(
 215                            f"  DEBUG: Found normalized match in MusicBrainz: '{mb_name}'"
 216                        )
 217
 218        if not result:
 219            if debug:
 220                print("  DEBUG: MusicBrainz found no matching results")
 221            return None
 222
 223        # Convert MusicBrainz data to Lidarr format
 224        mb_id = result.get("id")
 225        mb_name = result.get("name", "")
 226        artist_type = result.get("type", "").capitalize()
 227
 228        # Map MusicBrainz types to Lidarr types
 229        if artist_type == "Person":
 230            artist_type = "Person"
 231        elif artist_type == "Group":
 232            artist_type = "Group"
 233        else:
 234            artist_type = "Person"  # Default
 235
 236        return {
 237            "artistName": mb_name,
 238            "foreignArtistId": mb_id,
 239            "artistType": artist_type,
 240            "disambiguation": result.get("disambiguation", ""),
 241            "links": [],
 242            "images": [],
 243            "genres": [],
 244            "tags": [],
 245        }
 246
 247    except requests.exceptions.RequestException as e:
 248        if debug:
 249            print(f"  DEBUG: MusicBrainz query failed: {e}")
 250        return None
 251    except Exception as e:
 252        if debug:
 253            print(f"  DEBUG: Error parsing MusicBrainz response: {e}")
 254        return None
 255
 256
 257def normalize_artist_name(name: str) -> str:
 258    """
 259    Normalize artist name for better search matching.
 260
 261    Removes accents, special characters, and common variations.
 262    """
 263    import unicodedata
 264
 265    normalized = name
 266
 267    # Normalize different types of hyphens/dashes to regular hyphen
 268    # U+2010 (HYPHEN), U+2011 (NON-BREAKING HYPHEN), U+2012 (FIGURE DASH),
 269    # U+2013 (EN DASH), U+2014 (EM DASH), U+2015 (HORIZONTAL BAR)
 270    hyphen_chars = ["\u2010", "\u2011", "\u2012", "\u2013", "\u2014", "\u2015"]
 271    for hyphen in hyphen_chars:
 272        normalized = normalized.replace(hyphen, "-")
 273
 274    # Normalize different types of apostrophes/quotes to regular apostrophe
 275    # U+2019 (RIGHT SINGLE QUOTATION MARK), U+02BC (MODIFIER LETTER APOSTROPHE)
 276    # U+2018 (LEFT SINGLE QUOTATION MARK), U+201B (SINGLE HIGH-REVERSED-9 QUOTATION MARK)
 277    apostrophe_chars = ["\u2019", "\u02bc", "\u2018", "\u201b"]
 278    for apostrophe in apostrophe_chars:
 279        normalized = normalized.replace(apostrophe, "'")
 280
 281    # Handle ligatures before NFD normalization
 282    ligatures = {
 283        "œ": "oe",
 284        "Œ": "OE",
 285        "æ": "ae",
 286        "Æ": "AE",
 287        "": "fi",
 288        "": "fl",
 289    }
 290    for ligature, replacement in ligatures.items():
 291        normalized = normalized.replace(ligature, replacement)
 292
 293    # Remove unicode accents
 294    normalized = unicodedata.normalize("NFD", normalized)
 295    normalized = "".join(
 296        char for char in normalized if unicodedata.category(char) != "Mn"
 297    )
 298
 299    # Common replacements
 300    normalized = normalized.replace("&", "and")
 301    normalized = normalized.replace("/", " ")
 302
 303    return normalized
 304
 305
 306def artist_name_matches(result_name: str, search_name: str) -> bool:
 307    """
 308    Check if a result artist name matches the search name.
 309
 310    Returns True if they match exactly or very closely (normalized).
 311    """
 312    # Normalize both names for comparison
 313    norm_result = normalize_artist_name(result_name).lower().strip()
 314    norm_search = normalize_artist_name(search_name).lower().strip()
 315
 316    # Exact match
 317    if norm_result == norm_search:
 318        return True
 319
 320    # Match without "The"
 321    if norm_result.startswith("the "):
 322        norm_result = norm_result[4:]
 323    if norm_search.startswith("the "):
 324        norm_search = norm_search[4:]
 325
 326    return norm_result == norm_search
 327
 328
 329def filter_search_results(
 330    results: List[Dict[str, Any]], artist_name: str, debug: bool = False
 331) -> List[Dict[str, Any]]:
 332    """
 333    Filter search results to find artists that match the search name.
 334
 335    Search results can contain both artists and albums. We need to:
 336    1. Extract the artist from each result
 337    2. Check if the artist name matches
 338    3. Return only matching results
 339    """
 340    filtered = []
 341
 342    for result in results:
 343        # Extract artist from result (could be direct artist or nested in album)
 344        result_artist = None
 345        if "artist" in result:
 346            result_artist = result["artist"]
 347        elif "album" in result and isinstance(result["album"], dict):
 348            result_artist = result["album"].get("artist")
 349        elif "artistName" in result:
 350            result_artist = result
 351
 352        if result_artist:
 353            result_artist_name = result_artist.get("artistName", "")
 354            if artist_name_matches(result_artist_name, artist_name):
 355                filtered.append(result)
 356                if debug:
 357                    print(
 358                        f"  DEBUG: Matched '{result_artist_name}' to '{artist_name}'"
 359                    )
 360            elif debug:
 361                print(
 362                    f"  DEBUG: Rejected '{result_artist_name}' (doesn't match '{artist_name}')"
 363                )
 364
 365    return filtered
 366
 367
 368def search_artist_in_lidarr(
 369    client: ArrClient, artist_name: str, debug: bool = False
 370) -> List[Dict[str, Any]]:
 371    """
 372    Search for an artist in Lidarr's database with fallback strategies.
 373
 374    Tries multiple search approaches:
 375    1. Exact name from Spotify
 376    2. Normalized name (without accents/special chars)
 377    3. Name without leading "The"
 378    4. First word only (for multi-word names)
 379
 380    Always filters results to find best match.
 381    """
 382    # Try exact search first
 383    results = client.get("/api/v1/search", params={"term": artist_name})
 384    if debug and not results:
 385        print(f"  DEBUG: No results for exact search: '{artist_name}'")
 386    if results:
 387        if debug:
 388            print(f"  DEBUG: Found {len(results)} results for '{artist_name}'")
 389        # Filter to find exact or close matches
 390        filtered = filter_search_results(results, artist_name, debug)
 391        if filtered:
 392            return filtered
 393        if debug:
 394            print("  DEBUG: No exact match in results, trying fallbacks")
 395
 396    # Try normalized version (without accents, & -> and, etc.)
 397    normalized = normalize_artist_name(artist_name)
 398    if normalized != artist_name:
 399        results = client.get("/api/v1/search", params={"term": normalized})
 400        if debug and not results:
 401            print(f"  DEBUG: No results for normalized: '{normalized}'")
 402        if results:
 403            filtered = filter_search_results(results, artist_name, debug)
 404            if filtered:
 405                print(f"  Found using normalized name: '{normalized}'")
 406                return filtered
 407
 408    # Try without leading "The"
 409    if artist_name.lower().startswith("the "):
 410        without_the = artist_name[4:]
 411        results = client.get("/api/v1/search", params={"term": without_the})
 412        if debug and not results:
 413            print(f"  DEBUG: No results without 'The': '{without_the}'")
 414        if results:
 415            filtered = filter_search_results(results, artist_name, debug)
 416            if filtered:
 417                print(f"  Found without 'The': '{without_the}'")
 418                return filtered
 419
 420    # Last resort: Query MusicBrainz directly
 421    if debug:
 422        print("  DEBUG: Lidarr search failed, trying MusicBrainz directly")
 423
 424    mb_artist = search_musicbrainz_artist(artist_name, debug=debug)
 425    if mb_artist:
 426        print(f"  Found in MusicBrainz: '{mb_artist['artistName']}'")
 427        # Return in the same format as Lidarr search results
 428        # Wrap in a result structure similar to what Lidarr returns
 429        return [{"artist": mb_artist}]
 430
 431    if debug:
 432        print(f"  DEBUG: All search strategies failed for '{artist_name}'")
 433    return []
 434
 435
 436def monitor_artist_albums(
 437    client: ArrClient,
 438    artist_id: int,
 439    playlist_album_names: Set[str],
 440    search_albums: bool = False,
 441    debug: bool = False,
 442) -> tuple[int, int, int]:
 443    """
 444    Monitor specific albums for an artist in Lidarr.
 445
 446    Args:
 447        client: Lidarr API client
 448        artist_id: Lidarr artist ID
 449        playlist_album_names: Set of album names from playlists
 450        search_albums: Whether to trigger album search after monitoring
 451        debug: Enable debug output
 452
 453    Returns:
 454        Tuple of (matched_count, monitored_count, searched_count)
 455    """
 456    # Get artist info
 457    artist = client.get(f"/api/v1/artist/{artist_id}")
 458    if not artist:
 459        if debug:
 460            print(f"    DEBUG: Could not fetch artist {artist_id}")
 461        return (0, 0, 0)
 462
 463    artist_name = artist.get("artistName", "Unknown")
 464
 465    # Fetch albums separately using the album endpoint
 466    albums = client.get("/api/v1/album", params={"artistId": artist_id})
 467    if not isinstance(albums, list):
 468        albums = []
 469
 470    # Debug: show artist stats
 471    if debug:
 472        monitored = artist.get("monitored", False)
 473        statistics = artist.get("statistics", {})
 474        album_count = statistics.get("albumCount", 0)
 475        print(
 476            f"    DEBUG: Artist monitored={monitored}, albumCount={album_count}, albums fetched={len(albums)}"
 477        )
 478
 479    if not albums:
 480        if debug:
 481            print(f"    DEBUG: No albums found for {artist_name}")
 482        return (0, 0, 0)
 483
 484    matched_count = 0
 485    monitored_count = 0
 486    searched_count = 0
 487
 488    # Match playlist albums to Lidarr albums
 489    for album in albums:
 490        album_title = album.get("title", "")
 491        album_id = album.get("id")
 492        already_monitored = album.get("monitored", False)
 493
 494        # Check if album has any downloaded tracks
 495        statistics = album.get("statistics", {})
 496        track_file_count = statistics.get("trackFileCount", 0)
 497        total_track_count = statistics.get("totalTrackCount", 0)
 498        is_downloaded = track_file_count > 0
 499
 500        # Check if this album matches any in the playlists
 501        for playlist_album in playlist_album_names:
 502            # Normalize both for comparison
 503            if (
 504                normalize_artist_name(album_title).lower()
 505                == normalize_artist_name(playlist_album).lower()
 506            ):
 507                matched_count += 1
 508
 509                # Monitor album if not already monitored
 510                if not already_monitored:
 511                    if debug:
 512                        print(f"    Monitoring album: {album_title}")
 513
 514                    # Update album to monitored
 515                    album["monitored"] = True
 516                    try:
 517                        client.put(f"/api/v1/album/{album_id}", album)
 518                        monitored_count += 1
 519                    except Exception as e:
 520                        if debug:
 521                            print(
 522                                f"    DEBUG: Failed to monitor album {album_title}: {e}"
 523                            )
 524                else:
 525                    if debug:
 526                        print(f"    Album already monitored: {album_title}")
 527
 528                # Trigger search if album is not downloaded
 529                if not is_downloaded:
 530                    if debug:
 531                        print(
 532                            f"    Searching for album: {album_title} "
 533                            f"({track_file_count}/{total_track_count} tracks)"
 534                        )
 535                    try:
 536                        client.post(
 537                            "/api/v1/command",
 538                            {
 539                                "name": "AlbumSearch",
 540                                "albumIds": [album_id],
 541                            },
 542                        )
 543                        searched_count += 1
 544                    except Exception as e:
 545                        if debug:
 546                            print(
 547                                f"    DEBUG: Failed to search album {album_title}: {e}"
 548                            )
 549                elif debug:
 550                    print(
 551                        f"    Album already downloaded: {album_title} "
 552                        f"({track_file_count}/{total_track_count} tracks)"
 553                    )
 554
 555                break  # Found match, move to next album
 556
 557    return (matched_count, monitored_count, searched_count)
 558
 559
 560def get_existing_artists(
 561    client: ArrClient,
 562) -> tuple[Set[str], Set[str], Dict[str, int], Dict[str, int]]:
 563    """
 564    Get set of artist names and foreign IDs already in Lidarr.
 565
 566    Returns:
 567        Tuple of (normalized artist names set, foreign artist IDs set,
 568                  foreign_id -> lidarr_id mapping, normalized_name -> lidarr_id mapping)
 569    """
 570    artists = client.get("/api/v1/artist")
 571    names = set()
 572    foreign_ids = set()
 573    foreign_to_lidarr_id = {}
 574    name_to_lidarr_id = {}
 575
 576    for artist in artists:
 577        # Store normalized name
 578        name = artist.get("artistName", "")
 579        lidarr_id = artist.get("id")
 580
 581        if name:
 582            normalized_name = normalize_artist_name(name).lower()
 583            names.add(normalized_name)
 584            # Map normalized name to Lidarr ID
 585            if lidarr_id:
 586                name_to_lidarr_id[normalized_name] = lidarr_id
 587
 588        # Store foreign artist ID (MusicBrainz ID)
 589        foreign_id = artist.get("foreignArtistId")
 590        if foreign_id:
 591            foreign_ids.add(foreign_id)
 592            if lidarr_id:
 593                foreign_to_lidarr_id[foreign_id] = lidarr_id
 594
 595    return names, foreign_ids, foreign_to_lidarr_id, name_to_lidarr_id
 596
 597
 598def add_artist_to_lidarr(
 599    client: ArrClient,
 600    artist: Dict[str, Any],
 601    root_folder: str,
 602    quality_profile_id: int,
 603    metadata_profile_id: int,
 604    monitor: str = "all",
 605    debug: bool = False,
 606) -> Dict[str, Any]:
 607    """
 608    Add an artist to Lidarr.
 609
 610    Args:
 611        client: Lidarr API client
 612        artist: Artist data from search results
 613        root_folder: Root folder path for music
 614        quality_profile_id: Quality profile ID
 615        metadata_profile_id: Metadata profile ID
 616        monitor: Monitoring option (all, future, missing, existing, none)
 617        debug: Enable debug output
 618
 619    Returns:
 620        API response
 621    """
 622    # Check for suspicious fields in artist data
 623    if debug:
 624        suspicious_fields = ["folder", "path", "rootFolderPath"]
 625        found_suspicious = {
 626            k: artist.get(k) for k in suspicious_fields if k in artist
 627        }
 628        if found_suspicious:
 629            print(f"  DEBUG: Found fields in artist data: {found_suspicious}")
 630
 631    # CRITICAL FIX: Remove the folder field from artist data before building payload
 632    # The folder field contains paths like 'library/ArtistName' which causes
 633    # Lidarr to append it to rootFolderPath, creating duplicates like:
 634    # /neo/music/library + library/Artist = /neo/music/library/library/Artist
 635    artist = dict(artist)  # Make a copy to avoid modifying the original
 636    artist.pop("folder", None)
 637    artist.pop("path", None)
 638    artist.pop("rootFolderPath", None)
 639
 640    # Build payload with only writable fields from search result
 641    # IMPORTANT: Do not include 'folder' or 'path' fields as they can cause duplicate paths
 642    payload = {
 643        "artistName": artist.get("artistName"),
 644        "foreignArtistId": artist.get("foreignArtistId"),
 645        "qualityProfileId": quality_profile_id,
 646        "metadataProfileId": metadata_profile_id,
 647        "rootFolderPath": root_folder,
 648        "monitored": True,
 649        "albumFolder": True,
 650        "monitorNewItems": "all",
 651        # Include metadata from search
 652        "artistType": artist.get("artistType", ""),
 653        "disambiguation": artist.get("disambiguation", ""),
 654        "links": artist.get("links", []),
 655        "images": artist.get("images", []),
 656        "genres": artist.get("genres", []),
 657        "tags": artist.get("tags", []),
 658        # Add options
 659        "addOptions": {
 660            "monitor": monitor,
 661            "searchForMissingAlbums": False,
 662        },
 663    }
 664
 665    if debug:
 666        print(f"  DEBUG: Sending rootFolderPath={root_folder}")
 667        # Verify no folder/path in final payload
 668        if "folder" in payload or "path" in payload:
 669            print("  ERROR: folder or path still in payload!")
 670        else:
 671            print("  DEBUG: Confirmed no folder/path in payload")
 672
 673        # Show full payload for debugging
 674        print(f"  DEBUG: Full payload keys: {list(payload.keys())}")
 675
 676    return client.post("/api/v1/artist", payload)
 677
 678
 679def try_add_single_artist(
 680    lidarr: ArrClient,
 681    artist_name: str,
 682    artist_data: Dict[str, Any],
 683    root_folder: str,
 684    quality_profile_id: int,
 685    metadata_profile_id: int,
 686    monitor: str,
 687    ctx: CommandContext,
 688    existing_foreign_ids: Set[str] = None,
 689    debug: bool = False,
 690) -> tuple[bool, str, Dict[str, Any] | None]:
 691    """
 692    Attempt to add a single artist to Lidarr.
 693
 694    Args:
 695        lidarr: Lidarr API client
 696        artist_name: Artist name from Spotify
 697        artist_data: Artist data including albums
 698        root_folder: Root folder path
 699        quality_profile_id: Quality profile ID
 700        metadata_profile_id: Metadata profile ID
 701        monitor: Monitor mode
 702        ctx: Command context
 703        existing_foreign_ids: Set of foreign artist IDs already in Lidarr
 704
 705    Returns:
 706        Tuple of (success: bool, error_message: str, lidarr_name: str | None)
 707    """
 708    if existing_foreign_ids is None:
 709        existing_foreign_ids = set()
 710
 711    try:
 712        # Search for artist in Lidarr's MusicBrainz database
 713        search_results = search_artist_in_lidarr(
 714            lidarr, artist_name, debug=debug
 715        )
 716
 717        if not search_results:
 718            return (False, "Not found in MusicBrainz", None)
 719
 720        # Search returns both artists and albums - extract artist from first result
 721        first_result = search_results[0]
 722
 723        # If result has 'artist' field, it's an album result - extract the artist
 724        if "artist" in first_result:
 725            artist_match = first_result["artist"]
 726        # If result has 'album' field with nested artist
 727        elif "album" in first_result and isinstance(
 728            first_result["album"], dict
 729        ):
 730            album = first_result["album"]
 731            if "artist" in album:
 732                artist_match = album["artist"]
 733            else:
 734                return (
 735                    False,
 736                    f"Album result has no artist field: {list(album.keys())}",
 737                    None,
 738                )
 739        # If result has 'artistName', it's already an artist result
 740        elif "artistName" in first_result:
 741            artist_match = first_result
 742        else:
 743            # Unknown result type
 744            return (
 745                False,
 746                f"Unexpected search result format: {list(first_result.keys())}",
 747                None,
 748            )
 749
 750        artist_mb_name = artist_match.get("artistName", artist_name)
 751        foreign_artist_id = artist_match.get("foreignArtistId")
 752
 753        # Check if artist is already in Lidarr by foreign ID
 754        if foreign_artist_id and foreign_artist_id in existing_foreign_ids:
 755            print(f"  Found: {artist_mb_name}")
 756            print("  Already in Lidarr (detected by MusicBrainz ID)")
 757            return (False, "Already exists in Lidarr", artist_mb_name)
 758
 759        print(f"  Found: {artist_mb_name}")
 760        print(f"  Albums in playlists: {len(artist_data['albums'])}")
 761        for album in list(artist_data["albums"])[:3]:
 762            print(f"    - {album}")
 763        if len(artist_data["albums"]) > 3:
 764            print(f"    ... and {len(artist_data['albums']) - 3} more albums")
 765
 766        if not ctx.dry_run:
 767            result = add_artist_to_lidarr(
 768                lidarr,
 769                artist_match,
 770                root_folder,
 771                quality_profile_id,
 772                metadata_profile_id,
 773                monitor,
 774                debug=debug,
 775            )
 776
 777            if result and result.get("id"):
 778                actual_path = result.get("path", "unknown")
 779                print(f"  ✓ Added successfully (ID: {result['id']})")
 780
 781                if debug:
 782                    print(f"  DEBUG: Lidarr assigned path: {actual_path}")
 783
 784                # Check if Lidarr created a duplicate path
 785                if "library/library" in actual_path:
 786                    print("  ⚠️  WARNING: Lidarr created duplicate path!")
 787                    print(f"     We sent rootFolderPath={root_folder}")
 788                    print(f"     Lidarr created: {actual_path}")
 789
 790                return (True, "", artist_mb_name)
 791            else:
 792                return (
 793                    False,
 794                    "Failed to add artist (no ID returned)",
 795                    artist_mb_name,
 796                )
 797        else:
 798            print("  [DRY RUN] Would add this artist")
 799            return (True, "", artist_mb_name)
 800
 801    except Exception as e:
 802        return (False, f"Error: {str(e)}", None)
 803
 804
 805def run(
 806    lidarr_url: str,
 807    lidarr_api_key: str,
 808    spotify_client_id: str,
 809    spotify_client_secret: str,
 810    spotify_username: str,
 811    playlist_ids: List[str],
 812    root_folder: str,
 813    monitor: str,
 814    request_delay: float,
 815    dry_run: bool,
 816    no_confirm: bool,
 817    all_playlists: bool,
 818    debug: bool = False,
 819):
 820    """Execute the lidarr sync-spotify command."""
 821    # Create clients and context
 822    lidarr = ArrClient(lidarr_url, lidarr_api_key)
 823    ctx = CommandContext(dry_run, no_confirm)
 824
 825    # Determine if we need interactive mode
 826    use_interactive = not playlist_ids and spotify_username
 827
 828    # Initialize Spotify client (always use client credentials)
 829    print("Initializing Spotify client...")
 830    spotify = SpotifyClient(spotify_client_id, spotify_client_secret)
 831
 832    # Get playlist IDs interactively if needed
 833    if use_interactive:
 834        print(
 835            f"Fetching public playlists for user '{spotify_username}' "
 836            "from Spotify..."
 837        )
 838        user_playlists = spotify.get_user_playlists(spotify_username)
 839
 840        if not user_playlists:
 841            print(
 842                f"\nNo public playlists found for user '{spotify_username}'!"
 843            )
 844            print(
 845                "Note: Only public playlists are accessible. "
 846                "Private playlists cannot be listed."
 847            )
 848            return
 849
 850        print(f"Found {len(user_playlists)} public playlists\n")
 851
 852        if all_playlists:
 853            # Select all playlists automatically
 854            selected_ids = [p["id"] for p in user_playlists]
 855            print(f"Selecting all {len(selected_ids)} playlists\n")
 856        else:
 857            # Interactive selection with fzf
 858            print(
 859                "Use fzf to select playlists (TAB to select, "
 860                "ENTER to confirm, ESC to cancel)"
 861            )
 862
 863            selected_ids = select_with_fzf(
 864                user_playlists, "{name} (by {owner}, {tracks_total} tracks)"
 865            )
 866
 867            if not selected_ids:
 868                print("\nNo playlists selected. Exiting.")
 869                return
 870
 871            print(f"\nSelected {len(selected_ids)} playlist(s)\n")
 872
 873        playlist_ids = selected_ids
 874    elif not playlist_ids:
 875        print("\nError: No playlist IDs provided and no Spotify username set.")
 876        print(
 877            "Either provide playlist IDs as arguments or use "
 878            "--spotify-username for interactive mode."
 879        )
 880        print("\nExamples:")
 881        print(
 882            "  arr lidarr sync-spotify http://localhost:8686 -u your-username"
 883        )
 884        print(
 885            "  arr lidarr sync-spotify http://localhost:8686 "
 886            "PLAYLIST_ID_1 PLAYLIST_ID_2"
 887        )
 888        return
 889
 890    # Get Lidarr configuration
 891    quality_profile_id = get_quality_profile_id(lidarr)
 892    metadata_profile_id = get_metadata_profile_id(lidarr)
 893
 894    # Check Lidarr's naming configuration (only in debug mode)
 895    if debug:
 896        print("\nChecking Lidarr naming configuration...")
 897        try:
 898            naming_config = lidarr.get("/api/v1/config/naming")
 899            artist_folder_format = naming_config.get(
 900                "artistFolderFormat", "Not set"
 901            )
 902            print(f"  Artist Folder Format: {artist_folder_format}")
 903        except Exception as e:
 904            print(f"  Could not fetch naming config: {e}")
 905
 906    # Use specified root folder or auto-detect from Lidarr
 907    if (
 908        root_folder and root_folder != "/music"
 909    ):  # /music is the default from CLI
 910        if debug:
 911            print(f"\nUsing user-specified root folder: {root_folder}")
 912    else:
 913        root_folder = get_root_folder_path(lidarr)
 914        if debug:
 915            print(f"\nAuto-detected root folder from Lidarr: {root_folder}")
 916
 917    print("\nUsing Lidarr configuration:")
 918    print(f"  Quality Profile ID: {quality_profile_id}")
 919    print(f"  Metadata Profile ID: {metadata_profile_id}")
 920    print(f"  Root Folder: {root_folder}")
 921
 922    # Verify no duplicate segments in root folder
 923    _, has_duplicate, duplicate_seg = strip_duplicate_path_segments(
 924        root_folder
 925    )
 926    if has_duplicate:
 927        print(
 928            f"  WARNING: Root folder has duplicate segment '{duplicate_seg}'!"
 929        )
 930        print("  This should have been cleaned by get_root_folder_path()")
 931
 932    # Track all unique artists and their albums
 933    all_artists = {}  # artist_name -> {spotify_id, albums: set()}
 934    playlist_info = []
 935
 936    print_section_header("FETCHING SPOTIFY PLAYLISTS")
 937
 938    for playlist_id in playlist_ids:
 939        try:
 940            info = spotify.get_playlist_info(playlist_id)
 941            playlist_info.append(info)
 942            print(
 943                f"\nPlaylist: {info['name']} "
 944                f"(by {info['owner']}, {info['tracks_total']} tracks)"
 945            )
 946
 947            tracks = spotify.get_playlist_tracks(playlist_id)
 948            print(f"  Retrieved {len(tracks)} tracks")
 949
 950            # Extract artists and albums
 951            for track in tracks:
 952                # Add all track artists (including features) to the list
 953                for artist in track.get("artists", []):
 954                    artist_name = artist.get("name")
 955                    if artist_name:
 956                        if artist_name not in all_artists:
 957                            all_artists[artist_name] = {
 958                                "spotify_id": artist.get("id"),
 959                                "albums": set(),
 960                            }
 961
 962                # But only associate the album with the album's actual artists
 963                # (not featuring artists on the track)
 964                album = track.get("album")
 965                if album:
 966                    album_name = album.get("name")
 967                    album_artists = album.get("artists", [])
 968                    for artist in album_artists:
 969                        artist_name = artist.get("name")
 970                        if artist_name and artist_name in all_artists:
 971                            all_artists[artist_name]["albums"].add(album_name)
 972
 973        except Exception as e:
 974            print(f"Error fetching playlist {playlist_id}: {e}")
 975            continue
 976
 977    if not all_artists:
 978        print("\nNo artists found in playlists!")
 979        return
 980
 981    print(f"\n\nFound {len(all_artists)} unique artists across all playlists")
 982
 983    # Check which artists are already in Lidarr
 984    print_section_header("CHECKING LIDARR")
 985    print("Fetching existing artists from Lidarr...")
 986    (
 987        existing_names,
 988        existing_foreign_ids,
 989        foreign_to_lidarr_id,
 990        name_to_lidarr_id,
 991    ) = get_existing_artists(lidarr)
 992    print(f"Found {len(existing_names)} artists already in Lidarr")
 993
 994    # Separate artists into existing and missing
 995    artists_to_add = []
 996    artists_already_in_lidarr = []
 997
 998    for artist_name, artist_data in all_artists.items():
 999        # Check normalized name
1000        normalized_name = normalize_artist_name(artist_name).lower()
1001        if normalized_name in existing_names:
1002            artists_already_in_lidarr.append(artist_name)
1003        else:
1004            artists_to_add.append((artist_name, artist_data))
1005
1006    # Print summary
1007    print_section_header("SUMMARY")
1008    print_item_list(artists_already_in_lidarr, "Already in Lidarr")
1009
1010    if artists_to_add:
1011        print(f"\n→ Artists to add: {len(artists_to_add)}")
1012        if dry_run:
1013            # In dry-run mode, show all artists for manual addition
1014            for artist_name, _ in artists_to_add:
1015                print(f"  - {artist_name}")
1016        else:
1017            # In normal mode, show first 10 to avoid clutter
1018            for artist_name, _ in artists_to_add[:10]:
1019                print(f"  - {artist_name}")
1020            if len(artists_to_add) > 10:
1021                print(f"  ... and {len(artists_to_add) - 10} more")
1022    else:
1023        print("\nAll artists from the playlists are already in Lidarr!")
1024        return
1025
1026    # Ask for confirmation to proceed
1027    if not get_confirmation_decision(
1028        ctx, f"\nAdd {len(artists_to_add)} artists to Lidarr?"
1029    ):
1030        if not ctx.dry_run:
1031            print("Operation cancelled")
1032        return
1033
1034    # Add artists to Lidarr with retry mechanism
1035    print_section_header("ADDING ARTISTS TO LIDARR")
1036    print(
1037        f"Note: Adding {len(artists_to_add)} artists with delays "
1038        "to avoid overwhelming Lidarr..."
1039    )
1040
1041    added_count = 0
1042    dry_run_artists = []  # Track artists for dry-run output
1043    failed_artists = []  # Track failed artists for retry
1044    max_retries = 2
1045
1046    # First pass: try adding all artists
1047    for idx, (artist_name, artist_data) in enumerate(artists_to_add, 1):
1048        print(f"\n[{idx}/{len(artists_to_add)}] Searching for: {artist_name}")
1049
1050        success, error_msg, lidarr_name = try_add_single_artist(
1051            lidarr,
1052            artist_name,
1053            artist_data,
1054            root_folder,
1055            quality_profile_id,
1056            metadata_profile_id,
1057            monitor,
1058            ctx,
1059            existing_foreign_ids,
1060            debug,
1061        )
1062
1063        if success:
1064            if ctx.dry_run:
1065                dry_run_artists.append(
1066                    {
1067                        "spotify_name": artist_name,
1068                        "lidarr_name": lidarr_name or artist_name,
1069                        "albums": artist_data["albums"],
1070                    }
1071                )
1072            added_count += 1
1073        else:
1074            print(f"{error_msg}")
1075            failed_artists.append((artist_name, artist_data, error_msg))
1076
1077        # Add delay between requests
1078        if idx < len(artists_to_add):
1079            delay = request_delay if not ctx.dry_run else 0.5
1080            time.sleep(delay)
1081
1082    # Retry failed artists (up to max_retries times)
1083    retry_round = 1
1084    while failed_artists and retry_round <= max_retries and not ctx.dry_run:
1085        print_section_header(f"RETRY ROUND {retry_round}")
1086        print(f"Retrying {len(failed_artists)} failed artist(s)...")
1087
1088        still_failed = []
1089
1090        for idx, (artist_name, artist_data, prev_error) in enumerate(
1091            failed_artists, 1
1092        ):
1093            print(
1094                f"\n[Retry {retry_round}/{max_retries}] "
1095                f"[{idx}/{len(failed_artists)}] {artist_name}"
1096            )
1097            print(f"  Previous error: {prev_error}")
1098
1099            success, error_msg, lidarr_name = try_add_single_artist(
1100                lidarr,
1101                artist_name,
1102                artist_data,
1103                root_folder,
1104                quality_profile_id,
1105                metadata_profile_id,
1106                monitor,
1107                ctx,
1108                existing_foreign_ids,
1109                debug,
1110            )
1111
1112            if success:
1113                added_count += 1
1114                print("  ✓ Succeeded on retry!")
1115            else:
1116                print(f"  ✗ Still failing: {error_msg}")
1117                still_failed.append((artist_name, artist_data, error_msg))
1118
1119            # Add delay between retry requests
1120            if idx < len(failed_artists):
1121                time.sleep(request_delay)
1122
1123        failed_artists = still_failed
1124        retry_round += 1
1125
1126    failed_count = len(failed_artists)
1127
1128    # Monitor albums from playlists for ALL artists in Lidarr
1129    if not ctx.dry_run:
1130        print_section_header("MONITORING PLAYLIST ALBUMS")
1131        print("Checking which albums from playlists should be monitored...")
1132        print(
1133            "\nNote: Newly added artists may take a few moments for Lidarr to"
1134        )
1135        print("      fetch their discography from MusicBrainz. If albums are")
1136        print(
1137            "      missing, try running the sync again in a minute or trigger"
1138        )
1139        print("      a 'Refresh Artist' in Lidarr's UI.\n")
1140
1141        # Collect artists to monitor (newly added + already existing)
1142        artists_to_monitor = []
1143
1144        # Fetch updated artist list (includes newly added artists)
1145        print("Fetching updated artist list...")
1146        _, _, _, updated_name_to_lidarr_id = get_existing_artists(lidarr)
1147
1148        # Process all artists that have albums in playlists
1149        for artist_name, artist_data in all_artists.items():
1150            if not artist_data.get("albums"):
1151                continue
1152
1153            # Look up artist by normalized name (no MusicBrainz search needed!)
1154            normalized_name = normalize_artist_name(artist_name).lower()
1155            lidarr_id = updated_name_to_lidarr_id.get(normalized_name)
1156
1157            if lidarr_id:
1158                artists_to_monitor.append(
1159                    (artist_name, lidarr_id, artist_data["albums"])
1160                )
1161
1162        if not artists_to_monitor:
1163            print("No artists with playlist albums found in Lidarr")
1164        else:
1165            print(f"Processing {len(artists_to_monitor)} artists...")
1166            total_matched = 0
1167            total_monitored = 0
1168            total_searched = 0
1169            artists_needing_refresh = []
1170
1171            for artist_name, lidarr_id, playlist_albums in artists_to_monitor:
1172                print(f"\n  {artist_name}:")
1173                matched, monitored, searched = monitor_artist_albums(
1174                    lidarr,
1175                    lidarr_id,
1176                    playlist_albums,
1177                    search_albums=False,  # Don't auto-search for now
1178                    debug=debug,
1179                )
1180                total_matched += matched
1181                total_monitored += monitored
1182                total_searched += searched
1183
1184                if matched == 0:
1185                    # Check if artist has no albums at all (might need refresh)
1186                    artist_albums = lidarr.get(
1187                        "/api/v1/album", params={"artistId": lidarr_id}
1188                    )
1189                    if not isinstance(artist_albums, list):
1190                        artist_albums = []
1191
1192                    if not artist_albums:
1193                        artists_needing_refresh.append(
1194                            (artist_name, lidarr_id)
1195                        )
1196                        print("    No albums found - may need refresh")
1197                        if debug:
1198                            print(
1199                                f"    DEBUG: Artist has {len(artist_albums)} albums in API response"
1200                            )
1201                            print(
1202                                f"    DEBUG: Albums in playlist: {list(playlist_albums)[:3]}"
1203                            )
1204                    else:
1205                        print("    No matching albums found in Lidarr")
1206                        if debug:
1207                            print(
1208                                f"    DEBUG: Artist has {len(artist_albums)} albums in Lidarr"
1209                            )
1210                            if artist_albums:
1211                                print(
1212                                    f"    DEBUG: Sample Lidarr albums: {[a.get('title') for a in artist_albums[:3]]}"
1213                                )
1214                            print(
1215                                f"    DEBUG: Albums in playlist: {list(playlist_albums)[:3]}"
1216                            )
1217                elif monitored == 0:
1218                    print(f"    {matched} album(s) already monitored")
1219
1220            print(f"\n  Total albums matched: {total_matched}")
1221            print(f"  Total albums newly monitored: {total_monitored}")
1222            print(f"  Total album searches triggered: {total_searched}")
1223
1224            # Offer to refresh artists with no albums
1225            if artists_needing_refresh:
1226                print(
1227                    f"\n  {len(artists_needing_refresh)} artist(s) have no albums and may need refresh:"
1228                )
1229                for name, _ in artists_needing_refresh[:5]:
1230                    print(f"    - {name}")
1231                if len(artists_needing_refresh) > 5:
1232                    print(
1233                        f"    ... and {len(artists_needing_refresh) - 5} more"
1234                    )
1235
1236                if not ctx.no_confirm:
1237                    response = (
1238                        input("\n  Trigger refresh for these artists? (y/n): ")
1239                        .lower()
1240                        .strip()
1241                    )
1242                    if response in ["y", "yes"]:
1243                        print("\n  Triggering refresh...")
1244                        for artist_name, artist_id in artists_needing_refresh:
1245                            print(f"    Refreshing {artist_name}...", end=" ")
1246                            try:
1247                                lidarr.post(
1248                                    "/api/v1/command",
1249                                    {
1250                                        "name": "RefreshArtist",
1251                                        "artistId": artist_id,
1252                                    },
1253                                )
1254                                print("")
1255                            except Exception as e:
1256                                print(f"✗ ({e})")
1257                        print(
1258                            "\n  Refresh commands sent. Wait a moment and run sync again"
1259                        )
1260                        print("  to monitor albums from playlists.")
1261
1262    # Final summary
1263    print_section_header("FINAL SUMMARY")
1264    print(f"\nTotal playlists processed: {len(playlist_info)}")
1265    print(f"Total unique artists found: {len(all_artists)}")
1266    print(f"Artists already in Lidarr: {len(artists_already_in_lidarr)}")
1267    print(f"Artists to add: {len(artists_to_add)}")
1268    print(f"  - Successfully added: {added_count}")
1269    print(f"  - Failed after {max_retries} retries: {failed_count}")
1270
1271    # Show permanently failed artists with error details
1272    if failed_artists and not ctx.dry_run:
1273        print_section_header("PERMANENTLY FAILED ARTISTS")
1274        print(
1275            f"\nThe following {len(failed_artists)} artist(s) could not be "
1276            f"added after {max_retries} retry attempts:\n"
1277        )
1278        for artist_name, artist_data, error_msg in failed_artists:
1279            print(f"{artist_name}")
1280            print(f"    Error: {error_msg}")
1281            if artist_data.get("albums"):
1282                album_count = len(artist_data["albums"])
1283                print(f"    Albums in playlists: {album_count}")
1284        print(
1285            "\nTip: These failures may be due to:\n"
1286            "  - Artist not found in MusicBrainz\n"
1287            "  - Temporary API issues (try running again later)\n"
1288            "  - Network connectivity problems"
1289        )
1290
1291    if ctx.dry_run:
1292        print(
1293            "\n[DRY RUN] No changes were made. "
1294            "Remove --dry-run to add artists."
1295        )
1296        if dry_run_artists:
1297            print_section_header("ARTISTS TO ADD MANUALLY")
1298            print(
1299                "\nCopy the artist names below to search and add them "
1300                "manually in Lidarr:\n"
1301            )
1302            for artist in dry_run_artists:
1303                print(f"{artist['lidarr_name']}")
1304                if artist["spotify_name"] != artist["lidarr_name"]:
1305                    print(f"  (Spotify: {artist['spotify_name']})")
1306                if artist["albums"]:
1307                    album_count = len(artist["albums"])
1308                    print(f"  Albums in playlists: {album_count}")
1309            print(f"\n\nTotal: {len(dry_run_artists)} artists to add manually")
1310    elif added_count > 0:
1311        print(
1312            f"\nMonitoring mode: {monitor}\n"
1313            "New artists will start searching for albums based on "
1314            "your Lidarr settings."
1315        )