nftable-migration
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
15from lib import (
16 ArrClient,
17 CommandContext,
18 SpotifyClient,
19 get_confirmation_decision,
20 print_item_list,
21 print_section_header,
22 select_with_fzf,
23)
24
25
26def get_quality_profile_id(client: ArrClient) -> int:
27 """Get the first available quality profile ID."""
28 profiles = client.get("/api/v1/qualityprofile")
29 if profiles and len(profiles) > 0:
30 return profiles[0].get("id")
31 return 1 # Default fallback
32
33
34def get_metadata_profile_id(client: ArrClient) -> int:
35 """Get the first available metadata profile ID."""
36 profiles = client.get("/api/v1/metadataprofile")
37 if profiles and len(profiles) > 0:
38 return profiles[0].get("id")
39 return 1 # Default fallback
40
41
42def search_artist_in_lidarr(
43 client: ArrClient, artist_name: str
44) -> List[Dict[str, Any]]:
45 """Search for an artist in Lidarr's database."""
46 return client.get("/api/v1/search", params={"term": artist_name})
47
48
49def get_existing_artists(client: ArrClient) -> Set[str]:
50 """Get set of artist names already in Lidarr."""
51 artists = client.get("/api/v1/artist")
52 return {artist.get("artistName", "").lower() for artist in artists}
53
54
55def add_artist_to_lidarr(
56 client: ArrClient,
57 artist: Dict[str, Any],
58 root_folder: str,
59 quality_profile_id: int,
60 metadata_profile_id: int,
61 monitor: str = "all",
62) -> Dict[str, Any]:
63 """
64 Add an artist to Lidarr.
65
66 Args:
67 client: Lidarr API client
68 artist: Artist data from search results
69 root_folder: Root folder path for music
70 quality_profile_id: Quality profile ID
71 metadata_profile_id: Metadata profile ID
72 monitor: Monitoring option (all, future, missing, existing, none)
73
74 Returns:
75 API response
76 """
77 payload = {
78 "artistName": artist.get("artistName"),
79 "foreignArtistId": artist.get("foreignArtistId"),
80 "qualityProfileId": quality_profile_id,
81 "metadataProfileId": metadata_profile_id,
82 "rootFolderPath": root_folder,
83 "monitored": True,
84 "addOptions": {"monitor": monitor, "searchForMissingAlbums": False},
85 }
86 return client.post("/api/v1/artist", payload)
87
88
89def run(
90 lidarr_url: str,
91 lidarr_api_key: str,
92 spotify_client_id: str,
93 spotify_client_secret: str,
94 spotify_username: str,
95 playlist_ids: List[str],
96 root_folder: str,
97 monitor: str,
98 request_delay: float,
99 dry_run: bool,
100 no_confirm: bool,
101):
102 """Execute the lidarr sync-spotify command."""
103 # Create clients and context
104 lidarr = ArrClient(lidarr_url, lidarr_api_key)
105 ctx = CommandContext(dry_run, no_confirm)
106
107 # Determine if we need interactive mode
108 use_interactive = not playlist_ids and spotify_username
109
110 # Initialize Spotify client (always use client credentials)
111 print("Initializing Spotify client...")
112 spotify = SpotifyClient(spotify_client_id, spotify_client_secret)
113
114 # Get playlist IDs interactively if needed
115 if use_interactive:
116 print(
117 f"Fetching public playlists for user '{spotify_username}' "
118 "from Spotify..."
119 )
120 user_playlists = spotify.get_user_playlists(spotify_username)
121
122 if not user_playlists:
123 print(
124 f"\nNo public playlists found for user '{spotify_username}'!"
125 )
126 print(
127 "Note: Only public playlists are accessible. "
128 "Private playlists cannot be listed."
129 )
130 return
131
132 print(f"Found {len(user_playlists)} public playlists\n")
133 print(
134 "Use fzf to select playlists (TAB to select, "
135 "ENTER to confirm, ESC to cancel)"
136 )
137
138 # Use fzf for selection
139 selected_ids = select_with_fzf(
140 user_playlists, "{name} (by {owner}, {tracks_total} tracks)"
141 )
142
143 if not selected_ids:
144 print("\nNo playlists selected. Exiting.")
145 return
146
147 playlist_ids = selected_ids
148 print(f"\nSelected {len(playlist_ids)} playlist(s)\n")
149 elif not playlist_ids:
150 print(
151 "\nError: No playlist IDs provided and no Spotify username set."
152 )
153 print(
154 "Either provide playlist IDs as arguments or use "
155 "--spotify-username for interactive mode."
156 )
157 print("\nExamples:")
158 print(
159 " arr lidarr sync-spotify http://localhost:8686 "
160 "-u your-username"
161 )
162 print(
163 " arr lidarr sync-spotify http://localhost:8686 "
164 "PLAYLIST_ID_1 PLAYLIST_ID_2"
165 )
166 return
167
168 # Get Lidarr configuration
169 quality_profile_id = get_quality_profile_id(lidarr)
170 metadata_profile_id = get_metadata_profile_id(lidarr)
171
172 # Track all unique artists and their albums
173 all_artists = {} # artist_name -> {spotify_id, albums: set()}
174 playlist_info = []
175
176 print_section_header("FETCHING SPOTIFY PLAYLISTS")
177
178 for playlist_id in playlist_ids:
179 try:
180 info = spotify.get_playlist_info(playlist_id)
181 playlist_info.append(info)
182 print(
183 f"\nPlaylist: {info['name']} "
184 f"(by {info['owner']}, {info['tracks_total']} tracks)"
185 )
186
187 tracks = spotify.get_playlist_tracks(playlist_id)
188 print(f" Retrieved {len(tracks)} tracks")
189
190 # Extract artists and albums
191 for track in tracks:
192 for artist in track.get("artists", []):
193 artist_name = artist.get("name")
194 if artist_name:
195 if artist_name not in all_artists:
196 all_artists[artist_name] = {
197 "spotify_id": artist.get("id"),
198 "albums": set(),
199 }
200 # Track which albums appear in playlists
201 if track.get("album"):
202 all_artists[artist_name]["albums"].add(
203 track["album"]
204 )
205
206 except Exception as e:
207 print(f"Error fetching playlist {playlist_id}: {e}")
208 continue
209
210 if not all_artists:
211 print("\nNo artists found in playlists!")
212 return
213
214 print(f"\n\nFound {len(all_artists)} unique artists across all playlists")
215
216 # Check which artists are already in Lidarr
217 print_section_header("CHECKING LIDARR")
218 print("Fetching existing artists from Lidarr...")
219 existing_artists = get_existing_artists(lidarr)
220 print(f"Found {len(existing_artists)} artists already in Lidarr")
221
222 # Separate artists into existing and missing
223 artists_to_add = []
224 artists_already_in_lidarr = []
225
226 for artist_name, artist_data in all_artists.items():
227 if artist_name.lower() in existing_artists:
228 artists_already_in_lidarr.append(artist_name)
229 else:
230 artists_to_add.append((artist_name, artist_data))
231
232 # Print summary
233 print_section_header("SUMMARY")
234 print_item_list(artists_already_in_lidarr, "Already in Lidarr")
235
236 if artists_to_add:
237 print(f"\n→ Artists to add: {len(artists_to_add)}")
238 for artist_name, _ in artists_to_add[:10]:
239 print(f" - {artist_name}")
240 if len(artists_to_add) > 10:
241 print(f" ... and {len(artists_to_add) - 10} more")
242 else:
243 print("\nAll artists from the playlists are already in Lidarr!")
244 return
245
246 # Ask for confirmation to proceed
247 if not get_confirmation_decision(
248 ctx, f"\nAdd {len(artists_to_add)} artists to Lidarr?"
249 ):
250 if not ctx.dry_run:
251 print("Operation cancelled")
252 return
253
254 # Add artists to Lidarr
255 print_section_header("ADDING ARTISTS TO LIDARR")
256 print(
257 f"Note: Adding {len(artists_to_add)} artists with delays "
258 "to avoid overwhelming Lidarr..."
259 )
260
261 added_count = 0
262 failed_count = 0
263
264 for idx, (artist_name, artist_data) in enumerate(artists_to_add, 1):
265 print(f"\n[{idx}/{len(artists_to_add)}] Searching for: {artist_name}")
266
267 try:
268 # Search for artist in Lidarr's MusicBrainz database
269 search_results = search_artist_in_lidarr(lidarr, artist_name)
270
271 if not search_results:
272 print(f" ✗ No results found for {artist_name}")
273 failed_count += 1
274 # Small delay even on failure to avoid hammering the API
275 if idx < len(artists_to_add):
276 time.sleep(0.5)
277 continue
278
279 # Use the first result (most relevant)
280 artist_match = search_results[0]
281 artist_mb_name = artist_match.get("artistName", artist_name)
282
283 print(f" Found: {artist_mb_name}")
284 print(f" Albums in playlists: {len(artist_data['albums'])}")
285 for album in list(artist_data["albums"])[:3]:
286 print(f" - {album}")
287 if len(artist_data["albums"]) > 3:
288 print(
289 f" ... and {len(artist_data['albums']) - 3} more albums"
290 )
291
292 if not ctx.dry_run:
293 result = add_artist_to_lidarr(
294 lidarr,
295 artist_match,
296 root_folder,
297 quality_profile_id,
298 metadata_profile_id,
299 monitor,
300 )
301
302 if result and result.get("id"):
303 print(f" ✓ Added successfully (ID: {result['id']})")
304 added_count += 1
305 else:
306 print(" ✗ Failed to add artist")
307 failed_count += 1
308 else:
309 print(" [DRY RUN] Would add this artist")
310 added_count += 1
311
312 # Add a delay between requests to avoid overwhelming Lidarr
313 if idx < len(artists_to_add):
314 delay = request_delay if not ctx.dry_run else 0.5
315 time.sleep(delay)
316
317 except Exception as e:
318 print(f" ✗ Error: {e}")
319 failed_count += 1
320 # Small delay even on error
321 if idx < len(artists_to_add):
322 time.sleep(0.5)
323
324 # Final summary
325 print_section_header("FINAL SUMMARY")
326 print(f"\nTotal playlists processed: {len(playlist_info)}")
327 print(f"Total unique artists found: {len(all_artists)}")
328 print(f"Artists already in Lidarr: {len(artists_already_in_lidarr)}")
329 print(f"Artists to add: {len(artists_to_add)}")
330 print(f" - Successfully added: {added_count}")
331 print(f" - Failed: {failed_count}")
332
333 if ctx.dry_run:
334 print(
335 "\n[DRY RUN] No changes were made. "
336 "Remove --dry-run to add artists."
337 )
338 elif added_count > 0:
339 print(
340 f"\nMonitoring mode: {monitor}\n"
341 "New artists will start searching for albums based on "
342 "your Lidarr settings."
343 )