Commit d5f2c658f9d7
Changed files (4)
tools
arr
tools/arr/commands/jellyfin_sync_spotify.py
@@ -0,0 +1,381 @@
+"""
+Sync Spotify playlists to Jellyfin.
+
+This script:
+1. Fetches tracks from specified Spotify playlists
+2. Searches for matching tracks in Jellyfin library
+3. Creates corresponding playlists in Jellyfin with matched tracks
+4. Reports on matching success rate and missing tracks
+"""
+
+import time
+from typing import List, Tuple
+
+from lib import (
+ CommandContext,
+ JellyfinClient,
+ SpotifyClient,
+ print_section_header,
+ select_with_fzf,
+)
+
+
+def normalize_string(s: str) -> str:
+ """
+ Normalize a string for comparison.
+
+ Converts to lowercase, removes common punctuation, and extra spaces.
+
+ Args:
+ s: String to normalize
+
+ Returns:
+ Normalized string
+ """
+ import re
+
+ # Convert to lowercase
+ s = s.lower()
+ # Remove common punctuation
+ s = re.sub(r"['\",.:;!?()[\]{}]", "", s)
+ # Normalize whitespace
+ s = re.sub(r"\s+", " ", s).strip()
+ return s
+
+
+def match_track_in_jellyfin(
+ jellyfin: JellyfinClient,
+ track_name: str,
+ artist_names: List[str],
+ album_name: str,
+) -> Tuple[str | None, float]:
+ """
+ Search for a track in Jellyfin and find the best match.
+
+ Args:
+ jellyfin: Jellyfin API client
+ track_name: Track name from Spotify
+ artist_names: List of artist names from Spotify
+ album_name: Album name from Spotify
+
+ Returns:
+ Tuple of (jellyfin_item_id, confidence_score)
+ Returns (None, 0.0) if no match found
+ """
+ # Build search query with track and primary artist
+ primary_artist = artist_names[0] if artist_names else ""
+ query = f"{track_name} {primary_artist}"
+
+ # Search Jellyfin
+ results = jellyfin.search_tracks(query, limit=20)
+
+ if not results:
+ return None, 0.0
+
+ # Normalize search terms
+ norm_track = normalize_string(track_name)
+ norm_artists = {normalize_string(a) for a in artist_names}
+ norm_album = normalize_string(album_name)
+
+ best_match = None
+ best_score = 0.0
+
+ for item in results:
+ score = 0.0
+
+ # Check track name (weight: 40%)
+ item_name = normalize_string(item.get("Name", ""))
+ if item_name == norm_track:
+ score += 0.4
+ elif norm_track in item_name or item_name in norm_track:
+ score += 0.2
+
+ # Check artist names (weight: 40%)
+ item_artists = item.get("Artists", [])
+ item_artist_names = {normalize_string(a) for a in item_artists}
+ if norm_artists & item_artist_names: # Intersection
+ score += 0.4
+ elif any(
+ any(ia in na or na in ia for ia in item_artist_names)
+ for na in norm_artists
+ ):
+ score += 0.2
+
+ # Check album name (weight: 20%)
+ item_album = normalize_string(item.get("Album", ""))
+ if item_album == norm_album:
+ score += 0.2
+ elif norm_album in item_album or item_album in norm_album:
+ score += 0.1
+
+ if score > best_score:
+ best_score = score
+ best_match = item.get("Id")
+
+ return best_match, best_score
+
+
+def run(
+ jellyfin_url: str,
+ jellyfin_api_token: str,
+ jellyfin_user_id: str,
+ spotify_client_id: str,
+ spotify_client_secret: str,
+ spotify_username: str,
+ playlist_ids: List[str],
+ match_threshold: float,
+ dry_run: bool,
+ no_confirm: bool,
+ all_playlists: bool,
+ public: bool,
+):
+ """Execute the jellyfin sync-spotify command."""
+ # Create clients and context
+ jellyfin = JellyfinClient(
+ jellyfin_url, jellyfin_api_token, jellyfin_user_id
+ )
+ ctx = CommandContext(dry_run, no_confirm)
+
+ # Determine if we need interactive mode
+ use_interactive = not playlist_ids and spotify_username
+
+ # Initialize Spotify client
+ 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")
+
+ if all_playlists:
+ # Select all playlists automatically
+ selected_ids = [p["id"] for p in user_playlists]
+ print(f"Selecting all {len(selected_ids)} playlists\n")
+ else:
+ # Interactive selection with fzf
+ print(
+ "Use fzf to select playlists (TAB to select, "
+ "ENTER to confirm, ESC to cancel)"
+ )
+
+ selected_ids = select_with_fzf(
+ user_playlists, "{name} (by {owner}, {tracks_total} tracks)"
+ )
+
+ if not selected_ids:
+ print("\nNo playlists selected. Exiting.")
+ return
+
+ print(f"\nSelected {len(selected_ids)} playlist(s)\n")
+
+ playlist_ids = selected_ids
+ 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 jellyfin sync-spotify http://localhost:8096 "
+ "-u your-username"
+ )
+ print(
+ " arr jellyfin sync-spotify http://localhost:8096 "
+ "PLAYLIST_ID_1 PLAYLIST_ID_2"
+ )
+ return
+
+ # Check existing Jellyfin playlists
+ print("Fetching existing Jellyfin playlists...")
+ existing_playlists = jellyfin.get_playlists()
+ existing_playlist_names = {
+ normalize_string(p.get("Name", "")) for p in existing_playlists
+ }
+
+ print_section_header("SYNCING SPOTIFY PLAYLISTS TO JELLYFIN")
+
+ playlists_created = 0
+ playlists_skipped = 0
+ total_tracks = 0
+ matched_tracks = 0
+ failed_matches = []
+
+ for playlist_id in playlist_ids:
+ try:
+ # Get playlist info
+ info = spotify.get_playlist_info(playlist_id)
+ playlist_name = info["name"]
+ print(
+ f"\n\nPlaylist: {playlist_name} "
+ f"(by {info['owner']}, {info['tracks_total']} tracks)"
+ )
+
+ # Check if playlist already exists in Jellyfin
+ if normalize_string(playlist_name) in existing_playlist_names:
+ print(
+ f" ⚠ Playlist '{playlist_name}' already exists "
+ "in Jellyfin, skipping..."
+ )
+ playlists_skipped += 1
+ continue
+
+ # Get tracks
+ tracks = spotify.get_playlist_tracks(playlist_id)
+ print(f" Retrieved {len(tracks)} tracks from Spotify")
+
+ # Match tracks in Jellyfin
+ print(" Matching tracks in Jellyfin library...")
+ jellyfin_item_ids = []
+ local_failed = []
+
+ for idx, track in enumerate(tracks, 1):
+ track_name = track.get("name", "")
+ artist_names = [
+ a.get("name", "") for a in track.get("artists", [])
+ ]
+ album_name = track.get("album", "")
+
+ if not track_name or not artist_names:
+ continue
+
+ total_tracks += 1
+ print(
+ f" [{idx}/{len(tracks)}] Searching: "
+ f"{track_name} - {', '.join(artist_names)}"
+ )
+
+ item_id, score = match_track_in_jellyfin(
+ jellyfin, track_name, artist_names, album_name
+ )
+
+ if item_id and score >= match_threshold:
+ jellyfin_item_ids.append(item_id)
+ matched_tracks += 1
+ print(f" ✓ Matched (confidence: {score:.2f})")
+ else:
+ local_failed.append(
+ {
+ "track": track_name,
+ "artists": ", ".join(artist_names),
+ "album": album_name,
+ "score": score,
+ "playlist": playlist_name,
+ }
+ )
+ print(
+ f" ✗ No match (best score: {score:.2f}, "
+ f"threshold: {match_threshold:.2f})"
+ )
+
+ # Small delay to avoid hammering Jellyfin
+ time.sleep(0.1)
+
+ failed_matches.extend(local_failed)
+
+ # Create playlist in Jellyfin
+ if jellyfin_item_ids:
+ print(
+ f"\n Matched {len(jellyfin_item_ids)}/{len(tracks)} "
+ f"tracks ({len(jellyfin_item_ids)/len(tracks)*100:.1f}%)"
+ )
+
+ if not ctx.dry_run:
+ try:
+ result = jellyfin.create_playlist(
+ playlist_name, jellyfin_item_ids, public
+ )
+ if result and result.get("Id"):
+ print(
+ f" ✓ Created playlist in Jellyfin "
+ f"(ID: {result['Id']})"
+ )
+ playlists_created += 1
+ else:
+ print(" ✗ Failed to create playlist")
+ playlists_skipped += 1
+ except Exception as e:
+ print(f" ✗ Error creating playlist: {e}")
+ playlists_skipped += 1
+ else:
+ print(
+ f" [DRY RUN] Would create playlist "
+ f"'{playlist_name}' with {len(jellyfin_item_ids)} "
+ "tracks"
+ )
+ playlists_created += 1
+ else:
+ print(
+ "\n ✗ No tracks matched - playlist not created"
+ )
+ playlists_skipped += 1
+
+ except Exception as e:
+ print(f"\nError processing playlist {playlist_id}: {e}")
+ playlists_skipped += 1
+ continue
+
+ # Final summary
+ print_section_header("FINAL SUMMARY")
+ print(f"\nTotal playlists processed: {len(playlist_ids)}")
+ print(f" - Created: {playlists_created}")
+ print(f" - Skipped: {playlists_skipped}")
+ print(f"\nTotal tracks processed: {total_tracks}")
+ print(f" - Matched: {matched_tracks}")
+ print(f" - Failed to match: {len(failed_matches)}")
+ if total_tracks > 0:
+ print(
+ f" - Match rate: "
+ f"{matched_tracks/total_tracks*100:.1f}%"
+ )
+
+ if failed_matches:
+ print_section_header("FAILED MATCHES")
+ print(
+ f"\nThe following {len(failed_matches)} tracks could not be "
+ f"matched (threshold: {match_threshold:.2f}):\n"
+ )
+ for item in failed_matches[:20]: # Show first 20
+ print(
+ f" • {item['track']} - {item['artists']} "
+ f"(from '{item['playlist']}')"
+ )
+ print(
+ f" Album: {item['album']}, "
+ f"Best score: {item['score']:.2f}"
+ )
+ if len(failed_matches) > 20:
+ print(f"\n ... and {len(failed_matches) - 20} more")
+ print(
+ "\nTip: Lower --match-threshold if too many false negatives. "
+ "Default is 0.6."
+ )
+
+ if ctx.dry_run:
+ print(
+ "\n[DRY RUN] No changes were made. "
+ "Remove --dry-run to create playlists."
+ )
+ elif playlists_created > 0:
+ print(
+ f"\n✓ Successfully created {playlists_created} playlist(s) "
+ "in Jellyfin!"
+ )
tools/arr/arr
@@ -44,6 +44,12 @@ def lidarr():
pass
+@cli.group()
+def jellyfin():
+ """Manage Jellyfin media server."""
+ pass
+
+
@sonarr.command()
@click.argument("url")
@click.argument("api_key")
@@ -285,5 +291,78 @@ def lidarr_sync_spotify(url, api_key, spotify_client_id, spotify_client_secret,
)
+@jellyfin.command("sync-spotify")
+@click.option("--url", envvar="JELLYFIN_URL", default="http://localhost:8096", help="Jellyfin server URL")
+@click.option("--api-token", "-t", envvar="JELLYFIN_API_TOKEN", required=True, help="Jellyfin API token")
+@click.option("--user-id", "-i", envvar="JELLYFIN_USER_ID", required=True, help="Jellyfin user ID")
+@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.option("--playlist-id", "-p", "playlist_ids", multiple=True, help="Spotify playlist ID (can be specified multiple times)")
+@click.option("--all", "--all-playlists", "all_playlists", is_flag=True, help="Sync all user playlists (requires --spotify-username)")
+@click.option("--match-threshold", default=0.6, type=float, help="Minimum confidence score for track matching (0.0-1.0)")
+@click.option("--public", is_flag=True, help="Make created playlists public")
+@click.option("--dry-run", is_flag=True, help="Show what would be created without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def jellyfin_sync_spotify(url, api_token, user_id, spotify_client_id, spotify_client_secret, spotify_username, playlist_ids, match_threshold, all_playlists, public, dry_run, no_confirm):
+ """Sync Spotify playlists to Jellyfin.
+
+ Fetches tracks from Spotify playlists and creates matching playlists in Jellyfin.
+
+ Three modes of operation:
+ 1. Interactive selector (fzf): Use --spotify-username without --playlist-id or --all
+ 2. Sync all playlists: Use --spotify-username --all
+ 3. Sync specific playlists: Use --playlist-id (one or more times)
+
+ All options can be provided via flags or environment variables:
+ - JELLYFIN_URL (default: http://localhost:8096)
+ - JELLYFIN_API_TOKEN (get from Jellyfin Dashboard > API Keys)
+ - JELLYFIN_USER_ID (get from user profile URL)
+ - SPOTIFY_CLIENT_ID (get from https://developer.spotify.com/dashboard)
+ - SPOTIFY_CLIENT_SECRET
+ - SPOTIFY_USERNAME (optional, for interactive/all modes)
+
+ Examples:
+ # Interactive mode - select playlists with fzf
+ export JELLYFIN_API_TOKEN=your-token
+ export JELLYFIN_USER_ID=your-user-id
+ export SPOTIFY_CLIENT_ID=your-client-id
+ export SPOTIFY_CLIENT_SECRET=your-client-secret
+ export SPOTIFY_USERNAME=your-username
+ arr jellyfin sync-spotify
+
+ # Sync ALL playlists from a user
+ arr jellyfin sync-spotify -u username --all
+
+ # Sync specific playlists by ID
+ arr jellyfin sync-spotify \\
+ -p 37i9dQZF1DXcBWIGoYBM5M -p 37i9dQZF1DX0XUsuxWHRQd
+
+ # Dry run with interactive selection
+ arr jellyfin sync-spotify -u username --dry-run
+
+ # Lower match threshold for more matches (may have false positives)
+ arr jellyfin sync-spotify -u username --match-threshold 0.4
+
+ # Make playlists public
+ arr jellyfin sync-spotify -u username --public
+ """
+ from commands import jellyfin_sync_spotify
+ jellyfin_sync_spotify.run(
+ url,
+ api_token,
+ user_id,
+ spotify_client_id,
+ spotify_client_secret,
+ spotify_username,
+ list(playlist_ids),
+ match_threshold,
+ dry_run,
+ no_confirm,
+ all_playlists,
+ public
+ )
+
+
if __name__ == "__main__":
cli()
tools/arr/lib.py
@@ -448,6 +448,173 @@ def select_with_fzf(
sys.exit(1)
+class JellyfinClient:
+ """Client for Jellyfin API interactions."""
+
+ def __init__(self, base_url: str, api_token: str, user_id: str):
+ """
+ Initialize the Jellyfin API client.
+
+ Args:
+ base_url: Base URL of the Jellyfin service
+ (e.g., http://localhost:8096)
+ api_token: API token for authentication
+ user_id: User ID for playlist ownership
+ """
+ self.base_url = base_url.rstrip("/")
+ self.api_token = api_token
+ self.user_id = user_id
+ self.headers = {
+ "Authorization": f'MediaBrowser Token="{api_token}"',
+ "Content-Type": "application/json",
+ }
+
+ def get(
+ self, endpoint: str, params: Optional[Dict[str, Any]] = None
+ ) -> List[Dict[str, Any]] | Dict[str, Any]:
+ """
+ Make a GET request to the Jellyfin API.
+
+ Args:
+ endpoint: API endpoint path (e.g., /Items)
+ params: Optional query parameters
+
+ Returns:
+ JSON response data
+
+ Raises:
+ SystemExit: If the request fails
+ """
+ url = f"{self.base_url}{endpoint}"
+ try:
+ response = requests.get(
+ url, headers=self.headers, params=params, timeout=30
+ )
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ print(
+ f"Error fetching from Jellyfin {endpoint}: {e}",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ def post(
+ self, endpoint: str, payload: Dict[str, Any]
+ ) -> Dict[str, Any]:
+ """
+ Make a POST request to the Jellyfin API.
+
+ Args:
+ endpoint: API endpoint path (e.g., /Playlists)
+ payload: JSON payload to send
+
+ Returns:
+ JSON response data
+
+ Raises:
+ SystemExit: If the request fails
+ """
+ url = f"{self.base_url}{endpoint}"
+ try:
+ response = requests.post(
+ url, headers=self.headers, json=payload, timeout=30
+ )
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ print(
+ f"Error posting to Jellyfin {endpoint}: {e}",
+ file=sys.stderr,
+ )
+ if hasattr(e, "response") and e.response is not None:
+ 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)
+
+ def search_tracks(
+ self, query: str, limit: int = 50
+ ) -> List[Dict[str, Any]]:
+ """
+ Search for tracks in Jellyfin library.
+
+ Args:
+ query: Search query string
+ limit: Maximum number of results
+
+ Returns:
+ List of track items
+ """
+ params = {
+ "searchTerm": query,
+ "IncludeItemTypes": "Audio",
+ "Recursive": "true",
+ "Limit": limit,
+ "userId": self.user_id,
+ }
+ result = self.get("/Items", params=params)
+ return result.get("Items", [])
+
+ def get_playlists(self) -> List[Dict[str, Any]]:
+ """
+ Get all playlists for the user.
+
+ Returns:
+ List of playlist items
+ """
+ params = {
+ "IncludeItemTypes": "Playlist",
+ "Recursive": "true",
+ "userId": self.user_id,
+ }
+ result = self.get("/Items", params=params)
+ return result.get("Items", [])
+
+ def create_playlist(
+ self, name: str, item_ids: List[str], is_public: bool = False
+ ) -> Dict[str, Any]:
+ """
+ Create a new playlist in Jellyfin.
+
+ Args:
+ name: Playlist name
+ item_ids: List of Jellyfin item IDs to add
+ is_public: Whether the playlist is public
+
+ Returns:
+ Created playlist data
+ """
+ payload = {
+ "Name": name,
+ "Ids": item_ids,
+ "UserId": self.user_id,
+ "IsPublic": is_public,
+ }
+ return self.post("/Playlists", payload)
+
+ def add_to_playlist(
+ self, playlist_id: str, item_ids: List[str]
+ ) -> Dict[str, Any]:
+ """
+ Add items to an existing playlist.
+
+ Args:
+ playlist_id: Jellyfin playlist ID
+ item_ids: List of item IDs to add
+
+ Returns:
+ Response data
+ """
+ params = {"ids": ",".join(item_ids), "userId": self.user_id}
+ return self.post(f"/Playlists/{playlist_id}/Items", params)
+
+
class SpotifyClient:
"""Client for Spotify API interactions using client credentials flow."""
tools/arr/README.md
@@ -1,6 +1,6 @@
# arr - Unified CLI for *arr Services
-A command-line tool for managing Sonarr, Radarr, and Lidarr media servers.
+A command-line tool for managing Sonarr, Radarr, Lidarr, and Jellyfin media servers.
## Features
@@ -11,6 +11,8 @@ A command-line tool for managing Sonarr, Radarr, and Lidarr media servers.
- Retag album metadata
- Update library paths
- **Sync Spotify playlists** - automatically add artists from your Spotify playlists
+- **Jellyfin**:
+ - **Sync Spotify playlists to Jellyfin** - create playlists in Jellyfin from your Spotify playlists
## Installation
@@ -281,6 +283,209 @@ Monitoring mode: all
New artists will start searching for albums based on your Lidarr settings.
```
+## Jellyfin Playlist Sync
+
+Sync your Spotify playlists to Jellyfin by matching tracks in your Jellyfin library.
+
+### Prerequisites
+
+1. **Get Jellyfin API Token**:
+ - Log in to Jellyfin web interface
+ - Go to Dashboard → API Keys
+ - Click "+" to create a new API key
+ - Give it a name (e.g., "arr script")
+ - Copy the generated API token
+
+2. **Get Jellyfin User ID**:
+ - Go to Dashboard → Users
+ - Click on your username
+ - Look at the URL: `http://your-jellyfin/web/index.html#!/users/user.html?userId=USER_ID_HERE`
+ - Copy the User ID from the URL
+
+3. **Set up Spotify credentials** (same as Lidarr sync):
+ - Follow the Spotify setup instructions in the "Spotify Playlist Sync" section above
+ - You need: Client ID, Client Secret, and optionally your Spotify username
+
+### Quick Start
+
+```bash
+# Set environment variables
+export JELLYFIN_URL="http://localhost:8096" # Optional, defaults to http://localhost:8096
+export JELLYFIN_API_TOKEN="your-jellyfin-api-token"
+export JELLYFIN_USER_ID="your-jellyfin-user-id"
+export SPOTIFY_CLIENT_ID="your-spotify-client-id"
+export SPOTIFY_CLIENT_SECRET="your-spotify-client-secret"
+export SPOTIFY_USERNAME="your-spotify-username"
+
+# Interactive mode - select playlists with fzf (uses env vars)
+arr jellyfin sync-spotify
+
+# Or with flags
+arr jellyfin sync-spotify \
+ --url http://localhost:8096 \
+ -t your-token \
+ -i your-user-id \
+ -u your-username
+```
+
+### Usage
+
+The tool supports three modes of operation:
+
+| Mode | Command | Description |
+|------|---------|-------------|
+| **Interactive (fzf)** | `arr jellyfin sync-spotify -u USERNAME` | Select playlists interactively with fzf |
+| **All playlists** | `arr jellyfin sync-spotify -u USERNAME --all` | Sync all public playlists from user |
+| **Specific playlists** | `arr jellyfin sync-spotify -p ID1 -p ID2` | Sync specific playlists by ID |
+
+#### Mode 1: Interactive Selector with fzf (Recommended)
+
+Select playlists interactively using fzf. Requires `--spotify-username`.
+
+```bash
+# With environment variables (URL defaults to http://localhost:8096)
+arr jellyfin sync-spotify
+
+# Or with flags
+arr jellyfin sync-spotify -u your-spotify-username
+
+# With dry run
+arr jellyfin sync-spotify -u your-spotify-username --dry-run
+```
+
+**Interactive controls:**
+- `TAB`: Select/deselect a playlist
+- `↑/↓` or `j/k`: Navigate
+- `ENTER`: Confirm selection
+- `ESC` or `Ctrl+C`: Cancel
+- Type to filter playlists
+
+#### Mode 2: Sync ALL User Playlists
+
+Sync all public playlists from a Spotify user. Requires `--spotify-username` and `--all`.
+
+```bash
+# Sync all playlists (no fzf selection)
+arr jellyfin sync-spotify -u your-spotify-username --all
+
+# With dry run to preview
+arr jellyfin sync-spotify -u your-spotify-username --all --dry-run
+```
+
+#### Mode 3: Sync Specific Playlists by ID
+
+Sync specific playlists using `-p` flag (can be repeated).
+
+```bash
+# Sync specific playlists
+arr jellyfin sync-spotify \
+ -p PLAYLIST_ID_1 -p PLAYLIST_ID_2
+
+# With all options
+arr jellyfin sync-spotify \
+ --url http://localhost:8096 \
+ --api-token your-token \
+ --user-id your-user-id \
+ --spotify-client-id your-id \
+ --spotify-client-secret your-secret \
+ -p PLAYLIST_ID_1 -p PLAYLIST_ID_2
+```
+
+### Options
+
+```bash
+# Adjust match threshold (default: 0.6)
+# Lower = more matches but more false positives
+# Higher = fewer matches but more accurate
+arr jellyfin sync-spotify --match-threshold 0.4
+
+# Make playlists public (default: private)
+arr jellyfin sync-spotify --public
+
+# Skip confirmation prompts (for automation)
+arr jellyfin sync-spotify --no-confirm
+
+# Custom Jellyfin URL (or use JELLYFIN_URL env var)
+arr jellyfin sync-spotify --url http://jellyfin.example.com:8096
+```
+
+### How It Works
+
+1. Fetches tracks from specified Spotify playlist(s)
+2. For each track, searches your Jellyfin library using track name, artist, and album
+3. Uses fuzzy matching with configurable threshold (default: 0.6) to find best matches
+4. Creates corresponding playlists in Jellyfin with matched tracks
+5. Reports matching statistics and lists unmatched tracks
+
+**Matching Algorithm:**
+- Track name match: 40% weight
+- Artist name match: 40% weight
+- Album name match: 20% weight
+- Total score must be ≥ threshold (default 0.6) to be considered a match
+
+### Example Output
+
+```
+================================================================================
+SYNCING SPOTIFY PLAYLISTS TO JELLYFIN
+================================================================================
+
+
+Playlist: Chill Vibes (by username, 50 tracks)
+ Retrieved 50 tracks from Spotify
+ Matching tracks in Jellyfin library...
+ [1/50] Searching: Song Name - Artist Name
+ ✓ Matched (confidence: 0.85)
+ [2/50] Searching: Another Song - Another Artist
+ ✗ No match (best score: 0.45, threshold: 0.60)
+ ...
+
+ Matched 45/50 tracks (90.0%)
+
+ ✓ Created playlist in Jellyfin (ID: abc123)
+
+================================================================================
+FINAL SUMMARY
+================================================================================
+
+Total playlists processed: 1
+ - Created: 1
+ - Skipped: 0
+
+Total tracks processed: 50
+ - Matched: 45
+ - Failed to match: 5
+ - Match rate: 90.0%
+
+================================================================================
+FAILED MATCHES
+================================================================================
+
+The following 5 tracks could not be matched (threshold: 0.60):
+
+ • Song Name - Artist Name (from 'Chill Vibes')
+ Album: Album Name, Best score: 0.45
+ ...
+
+Tip: Lower --match-threshold if too many false negatives. Default is 0.6.
+
+✓ Successfully created 1 playlist(s) in Jellyfin!
+```
+
+### Troubleshooting
+
+**Playlists already exist**: The tool skips playlists that already exist in Jellyfin (matched by name). Delete the existing playlist in Jellyfin if you want to recreate it.
+
+**Low match rate**: If many tracks aren't matching:
+1. Check that the tracks actually exist in your Jellyfin library
+2. Lower the `--match-threshold` (try 0.4 or 0.5)
+3. Verify your music library has proper metadata (track names, artist names, album names)
+
+**No matches at all**: Verify that:
+- Your Jellyfin library is properly indexed
+- You're using the correct user ID (must have access to the music library)
+- The music library contains the artists/albums you're trying to match
+
## Other Commands
### Lidarr