Commit d5f2c658f9d7

Vincent Demeester <vincent@sbr.pm>
2025-12-02 21:55:16
feat: Add Jellyfin playlist sync from Spotify to arr tool
Add support for syncing Spotify playlists to Jellyfin media server with intelligent track matching. Features: - Three modes: interactive fzf selector, sync all playlists, or sync specific playlists by ID - JellyfinClient class with playlist creation and track search APIs - Fuzzy track matching algorithm (40% track name, 40% artist, 20% album) - Configurable match threshold (default: 0.6) - Support for environment variables for all credentials - Dry-run mode for previewing changes - Detailed match statistics and failed match reporting Usage examples: arr jellyfin sync-spotify -u username # Interactive fzf arr jellyfin sync-spotify -u username --all # Sync all playlists arr jellyfin sync-spotify -p ID1 -p ID2 # Specific playlists Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 3e52096
Changed files (4)
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