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        )