Commit 55f83c94a8a0
Changed files (3)
tools
arr
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(