main
1"""
2Sync Spotify playlists to Jellyfin.
3
4This script:
51. Fetches tracks from specified Spotify playlists
62. Searches for matching tracks in Jellyfin library
73. Creates corresponding playlists in Jellyfin with matched tracks
84. Reports on matching success rate and missing tracks
9"""
10
11import time
12from typing import List, Tuple
13
14from lib import (
15 CommandContext,
16 JellyfinClient,
17 SpotifyClient,
18 print_section_header,
19 select_with_fzf,
20)
21
22
23def normalize_string(s: str) -> str:
24 """
25 Normalize a string for comparison.
26
27 Converts to lowercase, removes common punctuation, and extra spaces.
28
29 Args:
30 s: String to normalize
31
32 Returns:
33 Normalized string
34 """
35 import re
36
37 # Convert to lowercase
38 s = s.lower()
39 # Remove common punctuation
40 s = re.sub(r"['\",.:;!?()[\]{}]", "", s)
41 # Normalize whitespace
42 s = re.sub(r"\s+", " ", s).strip()
43 return s
44
45
46def match_track_in_jellyfin(
47 jellyfin: JellyfinClient,
48 track_name: str,
49 artist_names: List[str],
50 album_name: str,
51 debug: bool = False,
52) -> Tuple[str | None, float]:
53 """
54 Search for a track in Jellyfin and find the best match.
55
56 Args:
57 jellyfin: Jellyfin API client
58 track_name: Track name from Spotify
59 artist_names: List of artist names from Spotify
60 album_name: Album name from Spotify
61
62 Returns:
63 Tuple of (jellyfin_item_id, confidence_score)
64 Returns (None, 0.0) if no match found
65 """
66 # Search by artist name (much more reliable than track name)
67 primary_artist = artist_names[0] if artist_names else ""
68
69 # Search Jellyfin using artist name
70 results = jellyfin.search_tracks(
71 query=f"{track_name} {primary_artist}", # Fallback legacy query
72 artist_name=primary_artist,
73 track_name=track_name,
74 limit=200 # Get more tracks since we're filtering by artist
75 )
76
77 if debug:
78 print(
79 f" DEBUG: Searched for artist '{primary_artist}', "
80 f"track '{track_name}' - found {len(results)} results"
81 )
82 if results and len(results) > 0:
83 first_name = results[0].get('Name', 'N/A')
84 first_artists = results[0].get('Artists', [])
85 print(
86 f" DEBUG: First result: {first_name} "
87 f"by {first_artists}"
88 )
89
90 if not results:
91 return None, 0.0
92
93 # Normalize search terms
94 norm_track = normalize_string(track_name)
95 norm_artists = {normalize_string(a) for a in artist_names}
96 norm_album = normalize_string(album_name)
97
98 best_match = None
99 best_score = 0.0
100
101 # Debug: track top 5 matches
102 top_matches = []
103
104 for item in results:
105 score = 0.0
106 score_breakdown = {"name": 0.0, "artist": 0.0, "album": 0.0}
107
108 # Check track name (weight: 40%)
109 item_name = normalize_string(item.get("Name", ""))
110 if item_name == norm_track:
111 score += 0.4
112 score_breakdown["name"] = 0.4
113 elif norm_track in item_name or item_name in norm_track:
114 score += 0.2
115 score_breakdown["name"] = 0.2
116
117 # Check artist names (weight: 40%)
118 item_artists = item.get("Artists", [])
119 item_artist_names = {normalize_string(a) for a in item_artists}
120 if norm_artists & item_artist_names: # Intersection
121 score += 0.4
122 score_breakdown["artist"] = 0.4
123 elif any(
124 any(ia in na or na in ia for ia in item_artist_names)
125 for na in norm_artists
126 ):
127 score += 0.2
128 score_breakdown["artist"] = 0.2
129
130 # Check album name (weight: 20%)
131 item_album = normalize_string(item.get("Album", ""))
132 if item_album == norm_album:
133 score += 0.2
134 score_breakdown["album"] = 0.2
135 elif norm_album in item_album or item_album in norm_album:
136 score += 0.1
137 score_breakdown["album"] = 0.1
138
139 if score > best_score:
140 best_score = score
141 best_match = item.get("Id")
142
143 # Track top matches for debugging
144 top_matches.append({
145 "id": item.get("Id"),
146 "name": item.get("Name", ""),
147 "artists": item.get("Artists", []),
148 "album": item.get("Album", ""),
149 "score": score,
150 "breakdown": score_breakdown
151 })
152
153 # Show top matches if debug is enabled
154 if debug:
155 top_matches.sort(key=lambda x: x["score"], reverse=True)
156 print(" DEBUG: Top 5 matches:")
157 for idx, match in enumerate(top_matches[:5], 1):
158 indicator = (
159 "<<<< SELECTED" if match.get("id") == best_match else ""
160 )
161 print(
162 f" {idx}. [{match['score']:.2f}] "
163 f"{match['name']} - {match['artists']} "
164 f"(Album: {match['album']}) {indicator}"
165 )
166 bd = match['breakdown']
167 print(
168 f" Score breakdown: name={bd['name']:.2f}, "
169 f"artist={bd['artist']:.2f}, album={bd['album']:.2f}"
170 )
171
172 # Find and show the selected track
173 selected = next(
174 (m for m in top_matches if m.get("id") == best_match), None
175 )
176 if selected:
177 print(
178 f" DEBUG: SELECTED TRACK: {selected['name']} "
179 f"from album '{selected['album']}'"
180 )
181
182 return best_match, best_score
183
184
185def run(
186 jellyfin_url: str,
187 jellyfin_api_token: str,
188 jellyfin_user_id: str,
189 spotify_client_id: str,
190 spotify_client_secret: str,
191 spotify_username: str,
192 playlist_ids: List[str],
193 all_playlists: bool,
194 match_threshold: float,
195 public: bool,
196 skip_existing: bool,
197 dry_run: bool,
198 no_confirm: bool,
199 debug: bool = False,
200):
201 """Execute the jellyfin sync-spotify command."""
202 # Create clients and context
203 jellyfin = JellyfinClient(
204 jellyfin_url, jellyfin_api_token, jellyfin_user_id, debug=debug
205 )
206 ctx = CommandContext(dry_run, no_confirm)
207
208 # Determine if we need interactive mode
209 use_interactive = not playlist_ids and spotify_username
210
211 # Initialize Spotify client
212 print("Initializing Spotify client...")
213 spotify = SpotifyClient(spotify_client_id, spotify_client_secret)
214
215 # Get playlist IDs interactively if needed
216 if use_interactive:
217 print(
218 f"Fetching public playlists for user '{spotify_username}' "
219 "from Spotify..."
220 )
221 user_playlists = spotify.get_user_playlists(spotify_username)
222
223 if not user_playlists:
224 print(
225 f"\nNo public playlists found for user '{spotify_username}'!"
226 )
227 print(
228 "Note: Only public playlists are accessible. "
229 "Private playlists cannot be listed."
230 )
231 return
232
233 print(f"Found {len(user_playlists)} public playlists\n")
234
235 if all_playlists:
236 # Select all playlists automatically
237 selected_ids = [p["id"] for p in user_playlists]
238 print(f"Selecting all {len(selected_ids)} playlists\n")
239 else:
240 # Interactive selection with fzf
241 print(
242 "Use fzf to select playlists (TAB to select, "
243 "ENTER to confirm, ESC to cancel)"
244 )
245
246 selected_ids = select_with_fzf(
247 user_playlists, "{name} (by {owner}, {tracks_total} tracks)"
248 )
249
250 if not selected_ids:
251 print("\nNo playlists selected. Exiting.")
252 return
253
254 print(f"\nSelected {len(selected_ids)} playlist(s)\n")
255
256 playlist_ids = selected_ids
257 elif not playlist_ids:
258 print(
259 "\nError: No playlist IDs provided and no Spotify username set."
260 )
261 print(
262 "Either provide playlist IDs as arguments or use "
263 "--spotify-username for interactive mode."
264 )
265 print("\nExamples:")
266 print(
267 " arr jellyfin sync-spotify http://localhost:8096 "
268 "-u your-username"
269 )
270 print(
271 " arr jellyfin sync-spotify http://localhost:8096 "
272 "PLAYLIST_ID_1 PLAYLIST_ID_2"
273 )
274 return
275
276 # Check existing Jellyfin playlists
277 print("Fetching existing Jellyfin playlists...")
278 existing_playlists = jellyfin.get_playlists()
279 # Map normalized names to playlist data (for idempotent updates)
280 existing_playlist_map = {
281 normalize_string(p.get("Name", "")): p
282 for p in existing_playlists
283 }
284
285 print_section_header("SYNCING SPOTIFY PLAYLISTS TO JELLYFIN")
286
287 playlists_created = 0
288 playlists_updated = 0
289 playlists_skipped = 0
290 total_tracks = 0
291 matched_tracks = 0
292 failed_matches = []
293
294 for playlist_id in playlist_ids:
295 try:
296 # Get playlist info
297 info = spotify.get_playlist_info(playlist_id)
298 if debug:
299 print(f"\n DEBUG: Playlist info: {info}")
300
301 playlist_name = info.get("name")
302 if not playlist_name or not isinstance(playlist_name, str):
303 print(
304 f"\n\nError: Invalid playlist name for {playlist_id}: "
305 f"{playlist_name}"
306 )
307 playlists_skipped += 1
308 continue
309
310 print(
311 f"\n\nPlaylist: {playlist_name} "
312 f"(by {info.get('owner', 'Unknown')}, "
313 f"{info.get('tracks_total', 0)} tracks)"
314 )
315
316 # Check if playlist already exists in Jellyfin
317 normalized_name = normalize_string(playlist_name)
318 existing_playlist = existing_playlist_map.get(normalized_name)
319
320 # Skip if playlist exists and skip_existing flag is set
321 if existing_playlist and skip_existing:
322 print(
323 f" ⚠ Playlist '{playlist_name}' already exists "
324 "in Jellyfin, skipping (--skip-existing enabled)..."
325 )
326 playlists_skipped += 1
327 continue
328
329 # Get tracks
330 tracks = spotify.get_playlist_tracks(playlist_id)
331 print(f" Retrieved {len(tracks)} tracks from Spotify")
332
333 # Match tracks in Jellyfin
334 print(" Matching tracks in Jellyfin library...")
335 jellyfin_item_ids = []
336 local_failed = []
337
338 for idx, track in enumerate(tracks, 1):
339 track_name = track.get("name", "")
340 artist_names = [
341 a.get("name", "") for a in track.get("artists", [])
342 ]
343 album_name = track.get("album", "")
344
345 if idx == 1 and debug: # Debug first track
346 print(f" DEBUG: Spotify track data: {track}")
347
348 if not track_name or not artist_names:
349 continue
350
351 total_tracks += 1
352 if not debug:
353 # Compact progress output when not debugging
354 print(
355 f" [{idx}/{len(tracks)}] {track_name} - "
356 f"{', '.join(artist_names)[:40]}...",
357 end="",
358 flush=True
359 )
360 else:
361 print(
362 f" [{idx}/{len(tracks)}] Searching: "
363 f"{track_name} - {', '.join(artist_names)}"
364 )
365 print(
366 f" DEBUG: track_name='{track_name}', "
367 f"artist_names={artist_names}, "
368 f"album_name='{album_name}'"
369 )
370
371 try:
372 item_id, score = match_track_in_jellyfin(
373 jellyfin, track_name, artist_names, album_name, debug
374 )
375
376 if item_id and score >= match_threshold:
377 jellyfin_item_ids.append(item_id)
378 matched_tracks += 1
379 if debug:
380 print(f" ✓ Matched (confidence: {score:.2f})")
381 else:
382 print(f" ✓ {score:.2f}")
383 else:
384 local_failed.append(
385 {
386 "track": track_name,
387 "artists": ", ".join(artist_names),
388 "album": album_name,
389 "score": score,
390 "playlist": playlist_name,
391 }
392 )
393 if debug:
394 print(
395 f" ✗ No match (best score: {score:.2f}, "
396 f"threshold: {match_threshold:.2f})"
397 )
398 else:
399 print(f" ✗ {score:.2f}")
400 except Exception as e:
401 print(
402 f"\n ⚠ Error matching track: {e}"
403 )
404 local_failed.append(
405 {
406 "track": track_name,
407 "artists": ", ".join(artist_names),
408 "album": album_name,
409 "score": 0.0,
410 "playlist": playlist_name,
411 }
412 )
413
414 # Reduced delay - artist search batches multiple tracks
415 time.sleep(0.05)
416
417 failed_matches.extend(local_failed)
418
419 # Create or update playlist in Jellyfin
420 if jellyfin_item_ids:
421 print(
422 f"\n Matched {len(jellyfin_item_ids)}/{len(tracks)} "
423 f"tracks ({len(jellyfin_item_ids)/len(tracks)*100:.1f}%)"
424 )
425
426 if not ctx.dry_run:
427 try:
428 if existing_playlist:
429 # Update existing playlist
430 playlist_jellyfin_id = existing_playlist.get("Id")
431 print(
432 f" ℹ Updating existing playlist "
433 f"(ID: {playlist_jellyfin_id})"
434 )
435
436 # Clear existing tracks
437 if jellyfin.clear_playlist(playlist_jellyfin_id):
438 # Add new tracks
439 jellyfin.add_to_playlist(
440 playlist_jellyfin_id, jellyfin_item_ids
441 )
442 print(
443 f" ✓ Updated playlist in Jellyfin "
444 f"with {len(jellyfin_item_ids)} tracks"
445 )
446 playlists_updated += 1
447 else:
448 print(" ✗ Failed to clear playlist")
449 playlists_skipped += 1
450 else:
451 # Create new playlist
452 result = jellyfin.create_playlist(
453 playlist_name, jellyfin_item_ids, public
454 )
455 if result and result.get("Id"):
456 print(
457 f" ✓ Created playlist in Jellyfin "
458 f"(ID: {result['Id']})"
459 )
460 playlists_created += 1
461 else:
462 print(" ✗ Failed to create playlist")
463 playlists_skipped += 1
464 except Exception as e:
465 print(f" ✗ Error updating/creating playlist: {e}")
466 playlists_skipped += 1
467 else:
468 action = "update" if existing_playlist else "create"
469 print(
470 f" [DRY RUN] Would {action} playlist "
471 f"'{playlist_name}' with {len(jellyfin_item_ids)} "
472 "tracks"
473 )
474 if existing_playlist:
475 playlists_updated += 1
476 else:
477 playlists_created += 1
478 else:
479 print(
480 "\n ✗ No tracks matched - playlist not created"
481 )
482 playlists_skipped += 1
483
484 except Exception as e:
485 print(f"\nError processing playlist {playlist_id}: {e}")
486 playlists_skipped += 1
487 continue
488
489 # Final summary
490 print_section_header("FINAL SUMMARY")
491 print(f"\nTotal playlists processed: {len(playlist_ids)}")
492 print(f" - Created: {playlists_created}")
493 print(f" - Updated: {playlists_updated}")
494 print(f" - Skipped: {playlists_skipped}")
495 print(f"\nTotal tracks processed: {total_tracks}")
496 print(f" - Matched: {matched_tracks}")
497 print(f" - Failed to match: {len(failed_matches)}")
498 if total_tracks > 0:
499 print(
500 f" - Match rate: "
501 f"{matched_tracks/total_tracks*100:.1f}%"
502 )
503
504 if failed_matches:
505 print_section_header("FAILED MATCHES")
506 print(
507 f"\nThe following {len(failed_matches)} tracks could not be "
508 f"matched (threshold: {match_threshold:.2f}):\n"
509 )
510 for item in failed_matches[:20]: # Show first 20
511 print(
512 f" • {item['track']} - {item['artists']} "
513 f"(from '{item['playlist']}')"
514 )
515 print(
516 f" Album: {item['album']}, "
517 f"Best score: {item['score']:.2f}"
518 )
519 if len(failed_matches) > 20:
520 print(f"\n ... and {len(failed_matches) - 20} more")
521 print(
522 "\nTip: Lower --match-threshold if too many false negatives. "
523 "Default is 0.6."
524 )
525
526 if ctx.dry_run:
527 print(
528 "\n[DRY RUN] No changes were made. "
529 "Remove --dry-run to create playlists."
530 )
531 elif playlists_created > 0 or playlists_updated > 0:
532 messages = []
533 if playlists_created > 0:
534 messages.append(f"created {playlists_created}")
535 if playlists_updated > 0:
536 messages.append(f"updated {playlists_updated}")
537 print(
538 f"\n✓ Successfully {' and '.join(messages)} playlist(s) "
539 "in Jellyfin!"
540 )