Commit 5bf232c55dc1

Vincent Demeester <vincent@sbr.pm>
2025-12-01 21:30:03
feat: Add Spotify playlist sync for Lidarr
- Enable automated artist discovery from user's Spotify playlists - Prevent API failures with retry logic and configurable rate limiting - Provide interactive playlist selection with fzf for better UX ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent f26d9ed
tools/arr/commands/lidarr_sync_spotify.py
@@ -0,0 +1,343 @@
+"""
+Sync Spotify playlists to Lidarr.
+
+This script:
+1. Fetches tracks from specified Spotify playlists
+2. Extracts unique artists from the playlist tracks
+3. Checks which artists are already in Lidarr
+4. Adds missing artists to Lidarr with monitoring options
+5. Optionally monitors specific albums that appear in playlists
+"""
+
+import time
+from typing import Any, Dict, List, Set
+
+from lib import (
+    ArrClient,
+    CommandContext,
+    SpotifyClient,
+    get_confirmation_decision,
+    print_item_list,
+    print_section_header,
+    select_with_fzf,
+)
+
+
+def get_quality_profile_id(client: ArrClient) -> int:
+    """Get the first available quality profile ID."""
+    profiles = client.get("/api/v1/qualityprofile")
+    if profiles and len(profiles) > 0:
+        return profiles[0].get("id")
+    return 1  # Default fallback
+
+
+def get_metadata_profile_id(client: ArrClient) -> int:
+    """Get the first available metadata profile ID."""
+    profiles = client.get("/api/v1/metadataprofile")
+    if profiles and len(profiles) > 0:
+        return profiles[0].get("id")
+    return 1  # Default fallback
+
+
+def search_artist_in_lidarr(
+    client: ArrClient, artist_name: str
+) -> List[Dict[str, Any]]:
+    """Search for an artist in Lidarr's database."""
+    return client.get("/api/v1/search", params={"term": artist_name})
+
+
+def get_existing_artists(client: ArrClient) -> Set[str]:
+    """Get set of artist names already in Lidarr."""
+    artists = client.get("/api/v1/artist")
+    return {artist.get("artistName", "").lower() for artist in artists}
+
+
+def add_artist_to_lidarr(
+    client: ArrClient,
+    artist: Dict[str, Any],
+    root_folder: str,
+    quality_profile_id: int,
+    metadata_profile_id: int,
+    monitor: str = "all",
+) -> Dict[str, Any]:
+    """
+    Add an artist to Lidarr.
+
+    Args:
+        client: Lidarr API client
+        artist: Artist data from search results
+        root_folder: Root folder path for music
+        quality_profile_id: Quality profile ID
+        metadata_profile_id: Metadata profile ID
+        monitor: Monitoring option (all, future, missing, existing, none)
+
+    Returns:
+        API response
+    """
+    payload = {
+        "artistName": artist.get("artistName"),
+        "foreignArtistId": artist.get("foreignArtistId"),
+        "qualityProfileId": quality_profile_id,
+        "metadataProfileId": metadata_profile_id,
+        "rootFolderPath": root_folder,
+        "monitored": True,
+        "addOptions": {"monitor": monitor, "searchForMissingAlbums": False},
+    }
+    return client.post("/api/v1/artist", payload)
+
+
+def run(
+    lidarr_url: str,
+    lidarr_api_key: str,
+    spotify_client_id: str,
+    spotify_client_secret: str,
+    spotify_username: str,
+    playlist_ids: List[str],
+    root_folder: str,
+    monitor: str,
+    request_delay: float,
+    dry_run: bool,
+    no_confirm: bool,
+):
+    """Execute the lidarr sync-spotify command."""
+    # Create clients and context
+    lidarr = ArrClient(lidarr_url, lidarr_api_key)
+    ctx = CommandContext(dry_run, no_confirm)
+
+    # Determine if we need interactive mode
+    use_interactive = not playlist_ids and spotify_username
+
+    # Initialize Spotify client (always use client credentials)
+    print("Initializing Spotify client...")
+    spotify = SpotifyClient(spotify_client_id, spotify_client_secret)
+
+    # Get playlist IDs interactively if needed
+    if use_interactive:
+        print(
+            f"Fetching public playlists for user '{spotify_username}' "
+            "from Spotify..."
+        )
+        user_playlists = spotify.get_user_playlists(spotify_username)
+
+        if not user_playlists:
+            print(
+                f"\nNo public playlists found for user '{spotify_username}'!"
+            )
+            print(
+                "Note: Only public playlists are accessible. "
+                "Private playlists cannot be listed."
+            )
+            return
+
+        print(f"Found {len(user_playlists)} public playlists\n")
+        print(
+            "Use fzf to select playlists (TAB to select, "
+            "ENTER to confirm, ESC to cancel)"
+        )
+
+        # Use fzf for selection
+        selected_ids = select_with_fzf(
+            user_playlists, "{name} (by {owner}, {tracks_total} tracks)"
+        )
+
+        if not selected_ids:
+            print("\nNo playlists selected. Exiting.")
+            return
+
+        playlist_ids = selected_ids
+        print(f"\nSelected {len(playlist_ids)} playlist(s)\n")
+    elif not playlist_ids:
+        print(
+            "\nError: No playlist IDs provided and no Spotify username set."
+        )
+        print(
+            "Either provide playlist IDs as arguments or use "
+            "--spotify-username for interactive mode."
+        )
+        print("\nExamples:")
+        print(
+            "  arr lidarr sync-spotify http://localhost:8686 "
+            "-u your-username"
+        )
+        print(
+            "  arr lidarr sync-spotify http://localhost:8686 "
+            "PLAYLIST_ID_1 PLAYLIST_ID_2"
+        )
+        return
+
+    # Get Lidarr configuration
+    quality_profile_id = get_quality_profile_id(lidarr)
+    metadata_profile_id = get_metadata_profile_id(lidarr)
+
+    # Track all unique artists and their albums
+    all_artists = {}  # artist_name -> {spotify_id, albums: set()}
+    playlist_info = []
+
+    print_section_header("FETCHING SPOTIFY PLAYLISTS")
+
+    for playlist_id in playlist_ids:
+        try:
+            info = spotify.get_playlist_info(playlist_id)
+            playlist_info.append(info)
+            print(
+                f"\nPlaylist: {info['name']} "
+                f"(by {info['owner']}, {info['tracks_total']} tracks)"
+            )
+
+            tracks = spotify.get_playlist_tracks(playlist_id)
+            print(f"  Retrieved {len(tracks)} tracks")
+
+            # Extract artists and albums
+            for track in tracks:
+                for artist in track.get("artists", []):
+                    artist_name = artist.get("name")
+                    if artist_name:
+                        if artist_name not in all_artists:
+                            all_artists[artist_name] = {
+                                "spotify_id": artist.get("id"),
+                                "albums": set(),
+                            }
+                        # Track which albums appear in playlists
+                        if track.get("album"):
+                            all_artists[artist_name]["albums"].add(
+                                track["album"]
+                            )
+
+        except Exception as e:
+            print(f"Error fetching playlist {playlist_id}: {e}")
+            continue
+
+    if not all_artists:
+        print("\nNo artists found in playlists!")
+        return
+
+    print(f"\n\nFound {len(all_artists)} unique artists across all playlists")
+
+    # Check which artists are already in Lidarr
+    print_section_header("CHECKING LIDARR")
+    print("Fetching existing artists from Lidarr...")
+    existing_artists = get_existing_artists(lidarr)
+    print(f"Found {len(existing_artists)} artists already in Lidarr")
+
+    # Separate artists into existing and missing
+    artists_to_add = []
+    artists_already_in_lidarr = []
+
+    for artist_name, artist_data in all_artists.items():
+        if artist_name.lower() in existing_artists:
+            artists_already_in_lidarr.append(artist_name)
+        else:
+            artists_to_add.append((artist_name, artist_data))
+
+    # Print summary
+    print_section_header("SUMMARY")
+    print_item_list(artists_already_in_lidarr, "Already in Lidarr")
+
+    if artists_to_add:
+        print(f"\nโ†’ Artists to add: {len(artists_to_add)}")
+        for artist_name, _ in artists_to_add[:10]:
+            print(f"  - {artist_name}")
+        if len(artists_to_add) > 10:
+            print(f"  ... and {len(artists_to_add) - 10} more")
+    else:
+        print("\nAll artists from the playlists are already in Lidarr!")
+        return
+
+    # Ask for confirmation to proceed
+    if not get_confirmation_decision(
+        ctx, f"\nAdd {len(artists_to_add)} artists to Lidarr?"
+    ):
+        if not ctx.dry_run:
+            print("Operation cancelled")
+        return
+
+    # Add artists to Lidarr
+    print_section_header("ADDING ARTISTS TO LIDARR")
+    print(
+        f"Note: Adding {len(artists_to_add)} artists with delays "
+        "to avoid overwhelming Lidarr..."
+    )
+
+    added_count = 0
+    failed_count = 0
+
+    for idx, (artist_name, artist_data) in enumerate(artists_to_add, 1):
+        print(f"\n[{idx}/{len(artists_to_add)}] Searching for: {artist_name}")
+
+        try:
+            # Search for artist in Lidarr's MusicBrainz database
+            search_results = search_artist_in_lidarr(lidarr, artist_name)
+
+            if not search_results:
+                print(f"  โœ— No results found for {artist_name}")
+                failed_count += 1
+                # Small delay even on failure to avoid hammering the API
+                if idx < len(artists_to_add):
+                    time.sleep(0.5)
+                continue
+
+            # Use the first result (most relevant)
+            artist_match = search_results[0]
+            artist_mb_name = artist_match.get("artistName", artist_name)
+
+            print(f"  Found: {artist_mb_name}")
+            print(f"  Albums in playlists: {len(artist_data['albums'])}")
+            for album in list(artist_data["albums"])[:3]:
+                print(f"    - {album}")
+            if len(artist_data["albums"]) > 3:
+                print(
+                    f"    ... and {len(artist_data['albums']) - 3} more albums"
+                )
+
+            if not ctx.dry_run:
+                result = add_artist_to_lidarr(
+                    lidarr,
+                    artist_match,
+                    root_folder,
+                    quality_profile_id,
+                    metadata_profile_id,
+                    monitor,
+                )
+
+                if result and result.get("id"):
+                    print(f"  โœ“ Added successfully (ID: {result['id']})")
+                    added_count += 1
+                else:
+                    print("  โœ— Failed to add artist")
+                    failed_count += 1
+            else:
+                print("  [DRY RUN] Would add this artist")
+                added_count += 1
+
+            # Add a delay between requests to avoid overwhelming Lidarr
+            if idx < len(artists_to_add):
+                delay = request_delay if not ctx.dry_run else 0.5
+                time.sleep(delay)
+
+        except Exception as e:
+            print(f"  โœ— Error: {e}")
+            failed_count += 1
+            # Small delay even on error
+            if idx < len(artists_to_add):
+                time.sleep(0.5)
+
+    # Final summary
+    print_section_header("FINAL SUMMARY")
+    print(f"\nTotal playlists processed: {len(playlist_info)}")
+    print(f"Total unique artists found: {len(all_artists)}")
+    print(f"Artists already in Lidarr: {len(artists_already_in_lidarr)}")
+    print(f"Artists to add: {len(artists_to_add)}")
+    print(f"  - Successfully added: {added_count}")
+    print(f"  - Failed: {failed_count}")
+
+    if ctx.dry_run:
+        print(
+            "\n[DRY RUN] No changes were made. "
+            "Remove --dry-run to add artists."
+        )
+    elif added_count > 0:
+        print(
+            f"\nMonitoring mode: {monitor}\n"
+            "New artists will start searching for albums based on "
+            "your Lidarr settings."
+        )
tools/arr/arr
@@ -124,5 +124,72 @@ def lidarr_update_paths(url, api_key, music_folder, dry_run):
     lidarr_update_paths.run(url, api_key, music_folder, dry_run)
 
 
+@lidarr.command("sync-spotify")
+@click.argument("url")
+@click.option("--api-key", "-k", envvar="LIDARR_API_KEY", required=True, help="Lidarr API key")
+@click.option("--spotify-client-id", envvar="SPOTIFY_CLIENT_ID", required=True, help="Spotify application client ID")
+@click.option("--spotify-client-secret", envvar="SPOTIFY_CLIENT_SECRET", required=True, help="Spotify application client secret")
+@click.option("--spotify-username", "-u", envvar="SPOTIFY_USERNAME", help="Spotify username for interactive mode (to list your playlists)")
+@click.argument("playlist_ids", nargs=-1, required=False)
+@click.option("--root-folder", default="/music", help="Root folder for music in Lidarr")
+@click.option("--monitor", default="all", type=click.Choice(["all", "future", "missing", "existing", "none"]), help="Album monitoring mode")
+@click.option("--request-delay", default=1.5, type=float, help="Delay between Lidarr API requests (seconds)")
+@click.option("--dry-run", is_flag=True, help="Show what would be added without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def lidarr_sync_spotify(url, api_key, spotify_client_id, spotify_client_secret, spotify_username, playlist_ids, root_folder, monitor, request_delay, dry_run, no_confirm):
+    """Sync Spotify playlists to Lidarr.
+
+    Fetches tracks from Spotify playlists and adds missing artists to Lidarr.
+
+    URL: Lidarr server URL (e.g., http://localhost:8686)
+
+    Credentials can be provided via flags or environment variables:
+    - LIDARR_API_KEY
+    - SPOTIFY_CLIENT_ID (get from https://developer.spotify.com/dashboard)
+    - SPOTIFY_CLIENT_SECRET
+    - SPOTIFY_USERNAME (optional, for interactive mode)
+
+    Interactive mode: If no PLAYLIST_IDS are provided and --spotify-username
+    is set, you'll be prompted to select from your public playlists using fzf.
+
+    Examples:
+        # Interactive mode - select from your playlists
+        export SPOTIFY_USERNAME=your-spotify-username
+        arr lidarr sync-spotify http://localhost:8686 -u your-username
+
+        # Or with environment variables
+        export LIDARR_API_KEY=your-lidarr-key
+        export SPOTIFY_CLIENT_ID=your-client-id
+        export SPOTIFY_CLIENT_SECRET=your-client-secret
+        export SPOTIFY_USERNAME=your-username
+        arr lidarr sync-spotify http://localhost:8686
+
+        # Sync specific playlists (no username needed)
+        arr lidarr sync-spotify http://localhost:8686 \\
+            37i9dQZF1DXcBWIGoYBM5M 37i9dQZF1DX0XUsuxWHRQd
+
+        # Dry run
+        arr lidarr sync-spotify http://localhost:8686 -u username --dry-run
+
+        # Monitor only future albums
+        arr lidarr sync-spotify http://localhost:8686 \\
+            -u username --monitor future
+    """
+    from commands import lidarr_sync_spotify
+    lidarr_sync_spotify.run(
+        url,
+        api_key,
+        spotify_client_id,
+        spotify_client_secret,
+        spotify_username,
+        list(playlist_ids),
+        root_folder,
+        monitor,
+        request_delay,
+        dry_run,
+        no_confirm
+    )
+
+
 if __name__ == "__main__":
     cli()
tools/arr/default.nix
@@ -6,7 +6,7 @@
 
 python3.pkgs.buildPythonApplication {
   pname = "arr";
-  version = "dev";
+  version = "1.1.0";
   format = "other";
 
   src = ./.;
@@ -16,6 +16,7 @@ python3.pkgs.buildPythonApplication {
   propagatedBuildInputs = with python3.pkgs; [
     click
     requests
+    spotipy
   ];
 
   # Don't try to create __pycache__ directories during build
@@ -47,7 +48,7 @@ python3.pkgs.buildPythonApplication {
     longDescription = ''
       arr provides a consistent interface for common operations across
       the *arr media management stack, including renaming, retagging,
-      and path updates.
+      path updates, and Spotify playlist syncing.
     '';
     platforms = platforms.unix;
   };
tools/arr/lib.py
@@ -6,7 +6,9 @@ Provides common functionality for API interaction, user confirmation,
 and output formatting across all *arr stack scripts.
 """
 
+import subprocess
 import sys
+import time
 from typing import Any, Dict, List, Optional
 
 import requests
@@ -29,42 +31,106 @@ class ArrClient:
         self.headers = {"X-Api-Key": api_key}
 
     def get(
-        self, endpoint: str, params: Optional[Dict[str, Any]] = None
+        self,
+        endpoint: str,
+        params: Optional[Dict[str, Any]] = None,
+        max_retries: int = 3,
+        retry_delay: float = 2.0,
     ) -> List[Dict[str, Any]] | Dict[str, Any]:
         """
-        Make a GET request to the *arr API.
+        Make a GET request to the *arr API with retry logic.
 
         Args:
             endpoint: API endpoint path (e.g., /api/v3/series)
             params: Optional query parameters
+            max_retries: Maximum number of retry attempts
+            retry_delay: Initial delay between retries (seconds)
 
         Returns:
             JSON response data
 
         Raises:
-            SystemExit: If the request fails
+            SystemExit: If the request fails after all retries
         """
         url = f"{self.base_url}{endpoint}"
 
-        try:
-            response = requests.get(url, headers=self.headers, params=params)
-            response.raise_for_status()
-            return response.json()
-        except requests.exceptions.RequestException as e:
-            print(f"Error fetching from {endpoint}: {e}", file=sys.stderr)
-            if params:
-                print(f"  Params: {params}", file=sys.stderr)
-            sys.exit(1)
+        for attempt in range(max_retries):
+            try:
+                response = requests.get(
+                    url, headers=self.headers, params=params, timeout=30
+                )
+                response.raise_for_status()
+                return response.json()
+            except requests.exceptions.HTTPError as e:
+                status_code = e.response.status_code if e.response else None
+
+                # Retry on server errors (5xx) or rate limiting (429)
+                if status_code in [429, 500, 502, 503, 504]:
+                    if attempt < max_retries - 1:
+                        wait_time = retry_delay * (2**attempt)
+                        print(
+                            f"  Server error ({status_code}), "
+                            f"retrying in {wait_time}s... "
+                            f"(attempt {attempt + 1}/{max_retries})"
+                        )
+                        time.sleep(wait_time)
+                        continue
+
+                # Don't retry on client errors (4xx except 429)
+                print(
+                    f"Error fetching from {endpoint}: HTTP {status_code}",
+                    file=sys.stderr,
+                )
+                if params:
+                    print(f"  Params: {params}", file=sys.stderr)
+                if e.response:
+                    try:
+                        error_detail = e.response.json()
+                        print(f"  Detail: {error_detail}", file=sys.stderr)
+                    except Exception:
+                        print(
+                            f"  Response: {e.response.text[:200]}",
+                            file=sys.stderr,
+                        )
+                sys.exit(1)
+            except requests.exceptions.Timeout:
+                if attempt < max_retries - 1:
+                    wait_time = retry_delay * (2**attempt)
+                    print(
+                        f"  Request timeout, retrying in {wait_time}s... "
+                        f"(attempt {attempt + 1}/{max_retries})"
+                    )
+                    time.sleep(wait_time)
+                    continue
+                print(f"Error: Request timeout on {endpoint}", file=sys.stderr)
+                sys.exit(1)
+            except requests.exceptions.RequestException as e:
+                print(f"Error fetching from {endpoint}: {e}", file=sys.stderr)
+                if params:
+                    print(f"  Params: {params}", file=sys.stderr)
+                sys.exit(1)
+
+        # Should not reach here, but just in case
+        print(
+            f"Error: Failed after {max_retries} attempts", file=sys.stderr
+        )
+        sys.exit(1)
 
     def post(
-        self, endpoint: str, payload: Dict[str, Any]
+        self,
+        endpoint: str,
+        payload: Dict[str, Any],
+        max_retries: int = 3,
+        retry_delay: float = 2.0,
     ) -> Dict[str, Any]:
         """
-        Make a POST request to the *arr API.
+        Make a POST request to the *arr API with retry logic.
 
         Args:
             endpoint: API endpoint path (e.g., /api/v3/command)
             payload: JSON payload to send
+            max_retries: Maximum number of retry attempts
+            retry_delay: Initial delay between retries (seconds)
 
         Returns:
             JSON response data (empty dict on failure)
@@ -72,13 +138,58 @@ class ArrClient:
         url = f"{self.base_url}{endpoint}"
         headers = {**self.headers, "Content-Type": "application/json"}
 
-        try:
-            response = requests.post(url, headers=headers, json=payload)
-            response.raise_for_status()
-            return response.json()
-        except requests.exceptions.RequestException as e:
-            print(f"Error posting to {endpoint}: {e}", file=sys.stderr)
-            return {}
+        for attempt in range(max_retries):
+            try:
+                response = requests.post(
+                    url, headers=headers, json=payload, timeout=30
+                )
+                response.raise_for_status()
+                return response.json()
+            except requests.exceptions.HTTPError as e:
+                status_code = e.response.status_code if e.response else None
+
+                # Retry on server errors (5xx) or rate limiting (429)
+                if status_code in [429, 500, 502, 503, 504]:
+                    if attempt < max_retries - 1:
+                        wait_time = retry_delay * (2**attempt)
+                        print(
+                            f"  Server error ({status_code}), "
+                            f"retrying in {wait_time}s... "
+                            f"(attempt {attempt + 1}/{max_retries})"
+                        )
+                        time.sleep(wait_time)
+                        continue
+
+                print(
+                    f"Error posting to {endpoint}: HTTP {status_code}",
+                    file=sys.stderr,
+                )
+                if e.response:
+                    try:
+                        error_detail = e.response.json()
+                        print(f"  Detail: {error_detail}", file=sys.stderr)
+                    except Exception:
+                        print(
+                            f"  Response: {e.response.text[:200]}",
+                            file=sys.stderr,
+                        )
+                return {}
+            except requests.exceptions.Timeout:
+                if attempt < max_retries - 1:
+                    wait_time = retry_delay * (2**attempt)
+                    print(
+                        f"  Request timeout, retrying in {wait_time}s... "
+                        f"(attempt {attempt + 1}/{max_retries})"
+                    )
+                    time.sleep(wait_time)
+                    continue
+                print(f"Error: Request timeout on {endpoint}", file=sys.stderr)
+                return {}
+            except requests.exceptions.RequestException as e:
+                print(f"Error posting to {endpoint}: {e}", file=sys.stderr)
+                return {}
+
+        return {}
 
 
 def ask_confirmation(prompt: str) -> bool:
@@ -201,3 +312,202 @@ def print_final_summary(
             f"\nNote: {operation} operations are queued. "
             "Check the service's queue for progress."
         )
+
+
+def select_with_fzf(
+    items: List[Dict[str, str]], display_format: str, multi: bool = True
+) -> List[str]:
+    """
+    Use fzf to interactively select items.
+
+    Args:
+        items: List of dictionaries containing item data
+        display_format: Format string for displaying items (e.g.,
+                       "{name} ({owner}, {tracks_total} tracks)")
+        multi: Allow multiple selection if True
+
+    Returns:
+        List of selected item IDs (empty list if cancelled)
+    """
+    if not items:
+        return []
+
+    # Create lookup table: display text -> item id
+    lookup = {}
+    lines = []
+    for item in items:
+        display = display_format.format(**item)
+        lines.append(display)
+        lookup[display] = item.get("id")
+
+    # Prepare fzf input
+    fzf_input = "\n".join(lines)
+
+    # Run fzf
+    fzf_args = ["fzf", "--ansi", "--prompt=Select playlists: "]
+    if multi:
+        fzf_args.append("--multi")
+
+    try:
+        result = subprocess.run(
+            fzf_args,
+            input=fzf_input,
+            text=True,
+            capture_output=True,
+            check=True,
+        )
+        # Parse selected lines
+        selected_lines = result.stdout.strip().split("\n")
+        return [lookup[line] for line in selected_lines if line in lookup]
+    except subprocess.CalledProcessError:
+        # User cancelled or fzf not found
+        return []
+    except FileNotFoundError:
+        print(
+            "Error: fzf not found. Please install fzf:", file=sys.stderr
+        )
+        print(
+            "  On NixOS: nix-env -iA nixpkgs.fzf", file=sys.stderr
+        )
+        print("  On other systems: see https://github.com/junegunn/fzf")
+        sys.exit(1)
+
+
+class SpotifyClient:
+    """Client for Spotify API interactions using client credentials flow."""
+
+    def __init__(self, client_id: str, client_secret: str):
+        """
+        Initialize the Spotify API client with client credentials.
+
+        This uses the client credentials flow which can access public
+        playlists but not private user data.
+
+        Args:
+            client_id: Spotify application client ID
+            client_secret: Spotify application client secret
+        """
+        try:
+            import spotipy
+            from spotipy.oauth2 import SpotifyClientCredentials
+        except ImportError:
+            print(
+                "Error: spotipy library not found. Install it with:",
+                file=sys.stderr,
+            )
+            print("  pip install spotipy", file=sys.stderr)
+            sys.exit(1)
+
+        # Use client credentials flow (no OAuth required)
+        auth_manager = SpotifyClientCredentials(
+            client_id=client_id, client_secret=client_secret
+        )
+        self.sp = spotipy.Spotify(auth_manager=auth_manager)
+
+    def get_playlist_tracks(self, playlist_id: str) -> List[Dict[str, Any]]:
+        """
+        Fetch all tracks from a Spotify playlist.
+
+        Args:
+            playlist_id: Spotify playlist ID or URI
+
+        Returns:
+            List of track information dictionaries
+        """
+        tracks = []
+        results = self.sp.playlist_tracks(playlist_id)
+
+        while results:
+            for item in results.get("items", []):
+                if item and item.get("track"):
+                    track = item["track"]
+                    tracks.append(
+                        {
+                            "name": track.get("name"),
+                            "artists": [
+                                {
+                                    "name": artist.get("name"),
+                                    "id": artist.get("id"),
+                                }
+                                for artist in track.get("artists", [])
+                            ],
+                            "album": track.get("album", {}).get("name"),
+                            "album_id": track.get("album", {}).get("id"),
+                        }
+                    )
+
+            # Handle pagination
+            if results.get("next"):
+                results = self.sp.next(results)
+            else:
+                results = None
+
+        return tracks
+
+    def get_playlist_info(self, playlist_id: str) -> Dict[str, Any]:
+        """
+        Get information about a Spotify playlist.
+
+        Args:
+            playlist_id: Spotify playlist ID or URI
+
+        Returns:
+            Playlist information dictionary
+        """
+        playlist = self.sp.playlist(playlist_id)
+        return {
+            "name": playlist.get("name"),
+            "description": playlist.get("description"),
+            "owner": playlist.get("owner", {}).get("display_name"),
+            "tracks_total": playlist.get("tracks", {}).get("total", 0),
+        }
+
+    def get_user_playlists(self, username: str) -> List[Dict[str, Any]]:
+        """
+        Fetch all public playlists for a specific user.
+
+        Args:
+            username: Spotify username (user ID)
+
+        Returns:
+            List of playlist information dictionaries
+        """
+        playlists = []
+        try:
+            results = self.sp.user_playlists(username)
+
+            while results:
+                for item in results.get("items", []):
+                    if item:
+                        playlists.append(
+                            {
+                                "id": item.get("id"),
+                                "name": item.get("name"),
+                                "owner": item.get("owner", {}).get(
+                                    "display_name"
+                                ),
+                                "tracks_total": item.get("tracks", {}).get(
+                                    "total", 0
+                                ),
+                                "public": item.get("public", False),
+                            }
+                        )
+
+                # Handle pagination
+                if results.get("next"):
+                    results = self.sp.next(results)
+                else:
+                    results = None
+
+        except Exception as e:
+            print(
+                f"Error fetching playlists for user '{username}': {e}",
+                file=sys.stderr,
+            )
+            print(
+                "Make sure the username is correct and the user has "
+                "public playlists.",
+                file=sys.stderr,
+            )
+
+        return playlists
tools/arr/README.md
@@ -0,0 +1,325 @@
+# arr - Unified CLI for *arr Services
+
+A command-line tool for managing Sonarr, Radarr, and Lidarr media servers.
+
+## Features
+
+- **Sonarr**: Rename TV series episodes
+- **Radarr**: Rename movies
+- **Lidarr**:
+  - Rename albums
+  - Retag album metadata
+  - Update library paths
+  - **Sync Spotify playlists** - automatically add artists from your Spotify playlists
+
+## Installation
+
+This package is built with Nix. From the repository root:
+
+```bash
+nix build .#arr
+./result/bin/arr --help
+```
+
+Or install to your profile:
+
+```bash
+nix profile install .#arr
+```
+
+## Spotify Playlist Sync
+
+### Quick Start
+
+1. **Create a Spotify App** to get API credentials:
+   - Go to https://developer.spotify.com/dashboard
+   - Log in with your Spotify account
+   - Click "Create an App"
+   - Fill in app name: "Lidarr Sync" (or any name)
+   - Accept Terms of Service and click "Create"
+   - Note your **Client ID** and **Client Secret** (click "Show Client Secret")
+   - No redirect URI needed!
+
+2. **Get your Spotify username** (for interactive mode):
+   - Go to https://www.spotify.com/account/
+   - Your username is shown under "Account overview"
+   - Or use any Spotify profile URL (the alphanumeric string after `/user/`)
+
+3. **Set environment variables**:
+   ```bash
+   export LIDARR_API_KEY="your-lidarr-api-key"
+   export SPOTIFY_CLIENT_ID="your-spotify-client-id"
+   export SPOTIFY_CLIENT_SECRET="your-spotify-client-secret"
+   export SPOTIFY_USERNAME="your-spotify-username"
+   ```
+
+4. **Run interactive mode**:
+   ```bash
+   arr lidarr sync-spotify http://localhost:8686
+   ```
+
+### Prerequisites
+
+1. **Create a Spotify App** to get API credentials:
+   - Go to https://developer.spotify.com/dashboard
+   - Log in with your Spotify account
+   - Click "Create an App"
+   - Fill in the app name (e.g., "Lidarr Sync")
+   - Accept the Terms of Service and click "Create"
+   - Note your **Client ID** and **Client Secret** (click "Show Client Secret")
+   - **No redirect URI or OAuth setup needed** - uses simple client credentials flow
+
+2. **Get your Spotify username** (for interactive mode):
+   - Go to https://www.spotify.com/account/ and look for "Username"
+   - Or open your profile in Spotify app โ†’ ... โ†’ Share โ†’ Copy profile link
+   - Extract the username from the URL: `https://open.spotify.com/user/USERNAME`
+
+3. **Install fzf** (for interactive playlist selection):
+   - On NixOS: `nix-env -iA nixpkgs.fzf` or include it in your system config
+   - On other systems: see https://github.com/junegunn/fzf
+
+4. **Get Playlist IDs** (optional - only needed for non-interactive mode):
+   - Open Spotify and navigate to your playlist
+   - Click "..." โ†’ "Share" โ†’ "Copy link to playlist"
+   - The ID is the alphanumeric string in the URL:
+     `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M`
+     โ†’ Playlist ID is `37i9dQZF1DXcBWIGoYBM5M`
+
+5. **Configure Lidarr**:
+   - Get your Lidarr API key from Settings โ†’ General โ†’ Security
+   - Note your Lidarr URL (e.g., `http://localhost:8686`)
+   - Set up a root folder for music in Settings โ†’ Media Management
+
+### Usage
+
+#### Interactive Mode (Recommended)
+
+The easiest way to use this tool is interactive mode. It will:
+1. Fetch all your **public** playlists from Spotify
+2. Show them in an interactive fzf menu
+3. Let you select multiple playlists with TAB
+4. Sync the selected playlists to Lidarr
+
+**Note**: Only public playlists can be accessed. Private playlists won't appear in the list.
+
+```bash
+# With environment variables (recommended for frequent use)
+export LIDARR_API_KEY="your-lidarr-api-key"
+export SPOTIFY_CLIENT_ID="your-spotify-client-id"
+export SPOTIFY_CLIENT_SECRET="your-spotify-client-secret"
+export SPOTIFY_USERNAME="your-spotify-username"
+
+arr lidarr sync-spotify http://localhost:8686
+
+# Or with flags
+arr lidarr sync-spotify http://localhost:8686 \
+    --api-key your-key \
+    --spotify-client-id your-id \
+    --spotify-client-secret your-secret \
+    --spotify-username your-username
+
+# Short form
+arr lidarr sync-spotify http://localhost:8686 -u your-username
+```
+
+**Interactive controls:**
+- `TAB`: Select/deselect a playlist
+- `โ†‘/โ†“` or `j/k`: Navigate
+- `ENTER`: Confirm selection
+- `ESC` or `Ctrl+C`: Cancel
+- Type to filter playlists
+
+#### Direct Mode (By Playlist ID)
+
+If you already know the playlist IDs, you can specify them directly:
+
+```bash
+# Sync a single playlist (with environment variables)
+arr lidarr sync-spotify http://localhost:8686 PLAYLIST_ID
+
+# Sync multiple playlists
+arr lidarr sync-spotify http://localhost:8686 \
+    PLAYLIST_ID_1 PLAYLIST_ID_2 PLAYLIST_ID_3
+
+# Or with flags
+arr lidarr sync-spotify http://localhost:8686 \
+    --api-key your-key \
+    --spotify-client-id your-id \
+    --spotify-client-secret your-secret \
+    PLAYLIST_ID_1 PLAYLIST_ID_2
+```
+
+#### Additional Options
+
+All options work with environment variables or flags:
+
+```bash
+# Dry run to preview changes (works in both modes)
+arr lidarr sync-spotify http://localhost:8686 --dry-run
+
+# Custom root folder
+arr lidarr sync-spotify http://localhost:8686 --root-folder /data/music
+
+# Monitor only future albums (don't search for existing releases)
+arr lidarr sync-spotify http://localhost:8686 --monitor future
+
+# Skip confirmation prompts (for automation)
+arr lidarr sync-spotify http://localhost:8686 --no-confirm
+
+# Adjust request delay (default: 1.5s) for slower/faster Lidarr instances
+arr lidarr sync-spotify http://localhost:8686 --request-delay 3.0
+
+# Combine options
+arr lidarr sync-spotify http://localhost:8686 \
+    --root-folder /data/music \
+    --monitor future \
+    --request-delay 2.0 \
+    --dry-run
+```
+
+### Performance & Rate Limiting
+
+The tool implements several features to avoid overwhelming Lidarr:
+
+- **Automatic retries**: Retries failed requests up to 3 times with exponential backoff
+- **Timeout handling**: 30-second timeout per request with automatic retry
+- **Request delays**: Configurable delay between artist additions (default: 1.5s)
+- **Error handling**: Graceful handling of 400/503 errors with detailed error messages
+
+If you experience timeout or rate limiting errors:
+1. Increase the delay: `--request-delay 3.0` or higher
+2. Check your Lidarr server performance (CPU/disk usage)
+3. Reduce the number of playlists processed at once
+
+### Monitoring Options
+
+The `--monitor` flag controls which albums Lidarr will monitor for each artist:
+
+- `all` (default): Monitor all albums
+- `future`: Only monitor future releases
+- `missing`: Only monitor missing albums
+- `existing`: Only monitor existing albums in your library
+- `none`: Don't monitor any albums
+
+### How It Works
+
+1. Connects to Spotify using client credentials (no browser authorization needed)
+2. Fetches all tracks from the specified Spotify playlist(s)
+3. Extracts unique artists from the playlist tracks
+4. Checks which artists are already in your Lidarr library
+5. Searches Lidarr's MusicBrainz database for missing artists
+6. Adds new artists to Lidarr with your specified monitoring settings
+7. Shows which albums from the playlist each artist has released
+
+### Important Notes
+
+- **Public Playlists Only**: The tool uses Spotify's client credentials flow, which can only access public playlists. Make sure your playlists are set to "Public" in Spotify if you want to use interactive mode.
+- **No OAuth Required**: Unlike the OAuth flow, you don't need to set up redirect URIs or authorize in a browser. Just create a Spotify app and use the credentials.
+- **Username Required for Interactive Mode**: To list your playlists, you need to provide your Spotify username. You can find it in your Spotify account settings.
+
+### Example Output
+
+```
+================================================================================
+FETCHING SPOTIFY PLAYLISTS
+================================================================================
+
+Playlist: Discover Weekly (by Spotify, 30 tracks)
+  Retrieved 30 tracks
+
+
+Found 25 unique artists across all playlists
+
+================================================================================
+CHECKING LIDARR
+================================================================================
+Fetching existing artists from Lidarr...
+Found 150 artists already in Lidarr
+
+================================================================================
+SUMMARY
+================================================================================
+
+Already in Lidarr (15 items):
+  - Artist 1
+  - Artist 2
+  ...
+
+โ†’ Artists to add: 10
+  - New Artist 1
+  - New Artist 2
+  ...
+
+Add 10 artists to Lidarr? (y/n): y
+
+================================================================================
+ADDING ARTISTS TO LIDARR
+================================================================================
+
+Searching for: New Artist 1
+  Found: New Artist 1
+  Albums in playlists: 3
+    - Album Name 1
+    - Album Name 2
+    - Album Name 3
+  โœ“ Added successfully (ID: 123)
+
+...
+
+================================================================================
+FINAL SUMMARY
+================================================================================
+
+Total playlists processed: 1
+Total unique artists found: 25
+Artists already in Lidarr: 15
+Artists to add: 10
+  - Successfully added: 10
+  - Failed: 0
+
+Monitoring mode: all
+New artists will start searching for albums based on your Lidarr settings.
+```
+
+## Other Commands
+
+### Lidarr
+
+```bash
+# Rename albums
+arr lidarr rename-albums http://localhost:8686 your-api-key
+
+# Retag album metadata
+arr lidarr retag-albums http://localhost:8686 your-api-key
+
+# Update library paths
+arr lidarr update-paths http://localhost:8686 your-api-key /data/music
+```
+
+### Sonarr
+
+```bash
+# Rename TV series episodes
+arr sonarr rename http://localhost:8989 your-api-key
+```
+
+### Radarr
+
+```bash
+# Rename movies
+arr radarr rename http://localhost:7878 your-api-key
+```
+
+All commands support `--dry-run` and `--no-confirm` flags.
+
+## Development
+
+The tool is structured as:
+- `arr` - Main CLI entry point
+- `lib.py` - Shared library (API clients, formatting utilities)
+- `commands/` - Individual command implementations
+
+To add a new command:
+1. Create a new file in `commands/`
+2. Add the command to the appropriate group in `arr`
+3. Update `default.nix` if new dependencies are needed