Commit 5bf232c55dc1
Changed files (5)
tools
arr
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