Commit 55f83c94a8a0

Vincent Demeester <vincent@sbr.pm>
2025-12-02 23:01:09
fix: Enable Jellyfin playlist sync with artist-based search
- Resolve Jellyfin SearchTerm API limitations using artist-first search strategy - Support username-to-user-ID conversion for simpler authentication - Add --debug flag for troubleshooting track matching issues Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent d5f2c65
Changed files (3)
tools/arr/commands/jellyfin_sync_spotify.py
@@ -48,6 +48,7 @@ def match_track_in_jellyfin(
     track_name: str,
     artist_names: List[str],
     album_name: str,
+    debug: bool = False,
 ) -> Tuple[str | None, float]:
     """
     Search for a track in Jellyfin and find the best match.
@@ -62,12 +63,29 @@ def match_track_in_jellyfin(
         Tuple of (jellyfin_item_id, confidence_score)
         Returns (None, 0.0) if no match found
     """
-    # Build search query with track and primary artist
+    # Search by artist name (much more reliable than track name)
     primary_artist = artist_names[0] if artist_names else ""
-    query = f"{track_name} {primary_artist}"
 
-    # Search Jellyfin
-    results = jellyfin.search_tracks(query, limit=20)
+    # Search Jellyfin using artist name
+    results = jellyfin.search_tracks(
+        query=f"{track_name} {primary_artist}",  # Fallback legacy query
+        artist_name=primary_artist,
+        track_name=track_name,
+        limit=200  # Get more tracks since we're filtering by artist
+    )
+
+    if debug:
+        print(
+            f"        DEBUG: Searched for artist '{primary_artist}', "
+            f"track '{track_name}' - found {len(results)} results"
+        )
+        if results and len(results) > 0:
+            first_name = results[0].get('Name', 'N/A')
+            first_artists = results[0].get('Artists', [])
+            print(
+                f"        DEBUG: First result: {first_name} "
+                f"by {first_artists}"
+            )
 
     if not results:
         return None, 0.0
@@ -80,38 +98,87 @@ def match_track_in_jellyfin(
     best_match = None
     best_score = 0.0
 
+    # Debug: track top 5 matches
+    top_matches = []
+
     for item in results:
         score = 0.0
+        score_breakdown = {"name": 0.0, "artist": 0.0, "album": 0.0}
 
         # Check track name (weight: 40%)
         item_name = normalize_string(item.get("Name", ""))
         if item_name == norm_track:
             score += 0.4
+            score_breakdown["name"] = 0.4
         elif norm_track in item_name or item_name in norm_track:
             score += 0.2
+            score_breakdown["name"] = 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
+            score_breakdown["artist"] = 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
+            score_breakdown["artist"] = 0.2
 
         # Check album name (weight: 20%)
         item_album = normalize_string(item.get("Album", ""))
         if item_album == norm_album:
             score += 0.2
+            score_breakdown["album"] = 0.2
         elif norm_album in item_album or item_album in norm_album:
             score += 0.1
+            score_breakdown["album"] = 0.1
 
         if score > best_score:
             best_score = score
             best_match = item.get("Id")
 
+        # Track top matches for debugging
+        top_matches.append({
+            "id": item.get("Id"),
+            "name": item.get("Name", ""),
+            "artists": item.get("Artists", []),
+            "album": item.get("Album", ""),
+            "score": score,
+            "breakdown": score_breakdown
+        })
+
+    # Show top matches if debug is enabled
+    if debug:
+        top_matches.sort(key=lambda x: x["score"], reverse=True)
+        print("        DEBUG: Top 5 matches:")
+        for idx, match in enumerate(top_matches[:5], 1):
+            indicator = (
+                "<<<< SELECTED" if match.get("id") == best_match else ""
+            )
+            print(
+                f"          {idx}. [{match['score']:.2f}] "
+                f"{match['name']} - {match['artists']} "
+                f"(Album: {match['album']}) {indicator}"
+            )
+            bd = match['breakdown']
+            print(
+                f"             Score breakdown: name={bd['name']:.2f}, "
+                f"artist={bd['artist']:.2f}, album={bd['album']:.2f}"
+            )
+
+        # Find and show the selected track
+        selected = next(
+            (m for m in top_matches if m.get("id") == best_match), None
+        )
+        if selected:
+            print(
+                f"        DEBUG: SELECTED TRACK: {selected['name']} "
+                f"from album '{selected['album']}'"
+            )
+
     return best_match, best_score
 
 
@@ -123,16 +190,17 @@ def run(
     spotify_client_secret: str,
     spotify_username: str,
     playlist_ids: List[str],
+    all_playlists: bool,
     match_threshold: float,
+    public: bool,
     dry_run: bool,
     no_confirm: bool,
-    all_playlists: bool,
-    public: bool,
+    debug: bool = False,
 ):
     """Execute the jellyfin sync-spotify command."""
     # Create clients and context
     jellyfin = JellyfinClient(
-        jellyfin_url, jellyfin_api_token, jellyfin_user_id
+        jellyfin_url, jellyfin_api_token, jellyfin_user_id, debug=debug
     )
     ctx = CommandContext(dry_run, no_confirm)
 
@@ -254,6 +322,9 @@ def run(
                 ]
                 album_name = track.get("album", "")
 
+                if idx == 1 and debug:  # Debug first track
+                    print(f"    DEBUG: Spotify track data: {track}")
+
                 if not track_name or not artist_names:
                     continue
 
@@ -262,9 +333,15 @@ def run(
                     f"    [{idx}/{len(tracks)}] Searching: "
                     f"{track_name} - {', '.join(artist_names)}"
                 )
+                if debug:
+                    print(
+                        f"        DEBUG: track_name='{track_name}', "
+                        f"artist_names={artist_names}, "
+                        f"album_name='{album_name}'"
+                    )
 
                 item_id, score = match_track_in_jellyfin(
-                    jellyfin, track_name, artist_names, album_name
+                    jellyfin, track_name, artist_names, album_name, debug
                 )
 
                 if item_id and score >= match_threshold:
tools/arr/arr
@@ -304,7 +304,8 @@ def lidarr_sync_spotify(url, api_key, spotify_client_id, spotify_client_secret,
 @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):
+@click.option("--debug", is_flag=True, help="Show debug output for troubleshooting")
+def jellyfin_sync_spotify(url, api_token, user_id, spotify_client_id, spotify_client_secret, spotify_username, playlist_ids, all_playlists, match_threshold, public, dry_run, no_confirm, debug):
     """Sync Spotify playlists to Jellyfin.
 
     Fetches tracks from Spotify playlists and creates matching playlists in Jellyfin.
@@ -356,11 +357,12 @@ def jellyfin_sync_spotify(url, api_token, user_id, spotify_client_id, spotify_cl
         spotify_client_secret,
         spotify_username,
         list(playlist_ids),
+        all_playlists,
         match_threshold,
+        public,
         dry_run,
         no_confirm,
-        all_playlists,
-        public
+        debug
     )
 
 
tools/arr/lib.py
@@ -451,7 +451,10 @@ def select_with_fzf(
 class JellyfinClient:
     """Client for Jellyfin API interactions."""
 
-    def __init__(self, base_url: str, api_token: str, user_id: str):
+    def __init__(
+        self, base_url: str, api_token: str, user_id: str,
+        debug: bool = False
+    ):
         """
         Initialize the Jellyfin API client.
 
@@ -459,16 +462,55 @@ class JellyfinClient:
             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
+            user_id: User ID or username for playlist ownership
+            debug: Enable debug output
         """
         self.base_url = base_url.rstrip("/")
         self.api_token = api_token
-        self.user_id = user_id
+        self.debug = debug
         self.headers = {
             "Authorization": f'MediaBrowser Token="{api_token}"',
             "Content-Type": "application/json",
         }
 
+        # Resolve username to user ID if needed
+        self.user_id = self._resolve_user_id(user_id)
+
+    def _resolve_user_id(self, user_identifier: str) -> str:
+        """
+        Resolve a username or user ID to a proper user ID.
+
+        If the identifier looks like a GUID, use it as-is.
+        Otherwise, look up the user by username.
+
+        Args:
+            user_identifier: Username or user ID
+
+        Returns:
+            Resolved user ID (GUID)
+        """
+        # Check if it's already a GUID (basic check for 8-4-4-4-12 format)
+        if len(user_identifier) == 32 or (
+            len(user_identifier) == 36 and user_identifier.count('-') == 4
+        ):
+            return user_identifier
+
+        # Otherwise, look up by username
+        try:
+            response = self.get("/Users")
+            if isinstance(response, list):
+                for user in response:
+                    user_name = user.get("Name", "").lower()
+                    if user_name == user_identifier.lower():
+                        return user.get("Id")
+        except Exception:
+            # If lookup fails, return the original identifier
+            # and let subsequent API calls fail with a clearer error
+            pass
+
+        # If not found, return original (might be unrecognized ID)
+        return user_identifier
+
     def get(
         self, endpoint: str, params: Optional[Dict[str, Any]] = None
     ) -> List[Dict[str, Any]] | Dict[str, Any]:
@@ -539,27 +581,132 @@ class JellyfinClient:
             sys.exit(1)
 
     def search_tracks(
-        self, query: str, limit: int = 50
+        self, query: str, limit: int = 50, artist_name: str = None,
+        track_name: str = None
     ) -> List[Dict[str, Any]]:
         """
         Search for tracks in Jellyfin library.
 
         Args:
-            query: Search query string
+            query: Legacy search query string (for backward compatibility)
             limit: Maximum number of results
+            artist_name: Artist name to search by (preferred)
+            track_name: Track name to search by
 
         Returns:
             List of track items
         """
+        # Prefer searching by artist name when available
+        if artist_name:
+            # Find artist using NameStartsWith (more reliable)
+            artist_words = artist_name.split()
+            artist_first_word = (
+                artist_words[0] if artist_words else artist_name
+            )
+
+            artist_params = {
+                "NameStartsWith": artist_first_word,
+                "IncludeItemTypes": "MusicArtist",
+                "Limit": 50,
+                "Recursive": True,
+            }
+            artist_result = self.get(
+                f"/Users/{self.user_id}/Items", params=artist_params
+            )
+
+            artists = artist_result.get("Items", [])
+
+            if self.debug:
+                print(
+                    f"DEBUG: Searching for artist starting with "
+                    f"'{artist_first_word}' - Found {len(artists)} artists"
+                )
+                if artists:
+                    for idx, artist in enumerate(artists[:5], 1):
+                        print(f"  {idx}. {artist.get('Name')}")
+
+            if artists:
+                # Find exact or best match
+                artist_name_lower = artist_name.lower()
+                matched_artist = None
+
+                for artist in artists:
+                    if artist.get('Name', '').lower() == artist_name_lower:
+                        matched_artist = artist
+                        break
+
+                # If no exact match, use first result
+                if not matched_artist:
+                    matched_artist = artists[0]
+
+                artist_id = matched_artist.get("Id")
+
+                if self.debug:
+                    artist_name_str = matched_artist.get('Name')
+                    print(
+                        f"DEBUG: Using artist: {artist_name_str} "
+                        f"(ID: {artist_id})"
+                    )
+
+                track_params = {
+                    "ArtistIds": artist_id,
+                    "IncludeItemTypes": "Audio",
+                    "Recursive": True,
+                    "Limit": limit,
+                    "Fields": (
+                        "Artists,Album,AlbumArtist,"
+                        "AlbumArtists,ArtistItems"
+                    ),
+                }
+                result = self.get(
+                    f"/Users/{self.user_id}/Items", params=track_params
+                )
+                items = result.get("Items", [])
+
+                if self.debug:
+                    print(f"DEBUG: Found {len(items)} tracks by this artist")
+
+                return items
+
+        # Fallback: search by track name
+        search_term = track_name or query
+        words = search_term.split()
+        first_word = words[0] if words else search_term
+
         params = {
-            "searchTerm": query,
+            "NameStartsWith": first_word,
             "IncludeItemTypes": "Audio",
-            "Recursive": "true",
-            "Limit": limit,
-            "userId": self.user_id,
+            "Recursive": True,
+            "Limit": 200,  # Larger limit: filtering client-side
+            "Fields": (
+                "Artists,Album,AlbumArtist,"
+                "AlbumArtists,ArtistItems"
+            ),
+            "EnableUserData": False,  # Skip user data for speed
         }
-        result = self.get("/Items", params=params)
-        return result.get("Items", [])
+
+        result = self.get(
+            f"/Users/{self.user_id}/Items", params=params
+        )
+
+        items = result.get("Items", [])
+
+        # Enrich items with album artist data if track artists are missing
+        for item in items:
+            if not item.get('Artists') and item.get('AlbumId'):
+                try:
+                    album_id = item['AlbumId']
+                    album = self.get(
+                        f"/Users/{self.user_id}/Items/{album_id}",
+                        params={"Fields": "Artists,AlbumArtists"}
+                    )
+                    # Use album artists as track artists
+                    item['Artists'] = album.get('AlbumArtists', [])
+                    item['Album'] = album.get('Name', '')
+                except Exception:
+                    pass  # Continue even if album fetch fails
+
+        return items
 
     def get_playlists(self) -> List[Dict[str, Any]]:
         """
@@ -571,9 +718,8 @@ class JellyfinClient:
         params = {
             "IncludeItemTypes": "Playlist",
             "Recursive": "true",
-            "userId": self.user_id,
         }
-        result = self.get("/Items", params=params)
+        result = self.get(f"/Users/{self.user_id}/Items", params=params)
         return result.get("Items", [])
 
     def create_playlist(