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": "fi",
288 "fl": "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 )