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        )