22from collections .abc import Callable
33from pathlib import Path
44from typing import Annotated , Optional
5-
5+ import re
6+ import spotipy
7+ import tidalapi
8+ from spotipy .oauth2 import SpotifyClientCredentials
69import typer
710from rich .live import Live
811from rich .panel import Panel
@@ -57,16 +60,19 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo
5760
5861 # Create initial objects.
5962 settings : Settings = Settings ()
60- progress : Progress = Progress (
63+
64+ # Create a single persistent progress display
65+ progress = Progress (
6166 "{task.description}" ,
6267 SpinnerColumn (),
6368 BarColumn (),
6469 TextColumn ("[progress.percentage]{task.percentage:>3.0f}%" ),
6570 refresh_per_second = 20 ,
6671 auto_refresh = True ,
6772 expand = True ,
68- transient = False , # Prevent progress from disappearing
73+ transient = False # Prevent progress from disappearing
6974 )
75+
7076 fn_logger = LoggerWrapped (progress .print )
7177 dl = Download (
7278 session = ctx .obj [CTX_TIDAL ].session ,
@@ -75,15 +81,18 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo
7581 fn_logger = fn_logger ,
7682 progress = progress ,
7783 )
78- progress_table = Table .grid ()
7984
80- # Style Progress display.
81- progress_table .add_row (Panel .fit (progress , title = "Download Progress" , border_style = "green" , padding = (2 , 2 )))
85+ progress_table = Table .grid ()
86+ progress_table .add_row (
87+ Panel .fit (progress , title = "Download Progress" , border_style = "green" , padding = (2 , 2 ))
88+ )
8289
8390 urls_pos_last = len (urls ) - 1
8491
85- # Use a single Live display for both progress and table
86- with Live (progress_table , refresh_per_second = 20 ):
92+ # Start the progress display
93+ progress .start ()
94+
95+ try :
8796 for item in urls :
8897 media_type : MediaType | bool = False
8998
@@ -94,7 +103,6 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo
94103 file_template = get_format_template (media_type , settings )
95104 else :
96105 print (f"It seems like that you have supplied an invalid URL: { item } " )
97-
98106 continue
99107
100108 # Download media.
@@ -122,11 +130,11 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo
122130 video_download = ctx .obj [CTX_TIDAL ].settings .data .video_download ,
123131 download_delay = settings .data .download_delay ,
124132 )
125-
126- # Clear and stop progress display
127- progress .refresh ()
128- progress .stop ()
129- print ("\n Download completed!" )
133+ finally :
134+ # Clear and stop progress display
135+ progress .refresh ()
136+ progress .stop ()
137+ print ("\n Downloads completed!" )
130138
131139 return True
132140
@@ -344,6 +352,215 @@ def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool:
344352 return _download (ctx , media_urls , try_login = False )
345353
346354
355+ def _validate_spotify_credentials (settings : Settings ) -> None :
356+ """Validate that Spotify API credentials are configured.
357+
358+ :param settings: The application settings.
359+ :type settings: Settings
360+ :raises typer.Exit: If credentials are not configured.
361+ """
362+ if not settings .data .spotify_client_id or not settings .data .spotify_client_secret :
363+ print ("Please set Spotify API credentials in config using:" )
364+ print ("tidal-dl-ng cfg spotify_client_id YOUR_CLIENT_ID" )
365+ print ("tidal-dl-ng cfg spotify_client_secret YOUR_CLIENT_SECRET" )
366+ raise typer .Exit (1 )
367+
368+
369+ def _extract_spotify_id (spotify_url : str ) -> tuple [str , str ]:
370+ """Extract ID and type from a Spotify URL.
371+
372+ :param spotify_url: The Spotify URL to parse.
373+ :type spotify_url: str
374+ :return: A tuple containing the content type and ID.
375+ :rtype: tuple[str, str]
376+ :raises typer.Exit: If the URL is invalid.
377+ """
378+ playlist_match = re .search (r'playlist/([a-zA-Z0-9]+)' , spotify_url )
379+ album_match = re .search (r'album/([a-zA-Z0-9]+)' , spotify_url )
380+ track_match = re .search (r'track/([a-zA-Z0-9]+)' , spotify_url )
381+
382+ if playlist_match :
383+ return "playlist" , playlist_match .group (1 )
384+ elif album_match :
385+ return "album" , album_match .group (1 )
386+ elif track_match :
387+ return "track" , track_match .group (1 )
388+ else :
389+ print ("Invalid Spotify URL. Please provide a valid Spotify playlist, album, or track URL." )
390+ raise typer .Exit (1 )
391+
392+
393+ def _fetch_spotify_tracks (sp : spotipy .Spotify , content_type : str , content_id : str ) -> list :
394+ """Fetch tracks from Spotify based on content type and ID.
395+
396+ :param sp: The Spotify client.
397+ :type sp: spotipy.Spotify
398+ :param content_type: The type of content ('playlist', 'album', or 'track').
399+ :type content_type: str
400+ :param content_id: The Spotify ID of the content.
401+ :type content_id: str
402+ :return: A list of tracks.
403+ :rtype: list
404+ """
405+ tracks = []
406+
407+ if content_type == "playlist" :
408+ print (f"Fetching Spotify playlist: { content_id } " )
409+
410+ # Get all playlist tracks with pagination
411+ results = sp .playlist_tracks (content_id )
412+ tracks .extend (results ['items' ])
413+
414+ while results ['next' ]:
415+ results = sp .next (results )
416+ tracks .extend (results ['items' ])
417+ elif content_type == "album" :
418+ print (f"Fetching Spotify album: { content_id } " )
419+
420+ # Get album information
421+ album = sp .album (content_id )
422+
423+ # Get all album tracks with pagination
424+ results = sp .album_tracks (content_id )
425+
426+ # Convert album tracks to the same format as playlist tracks
427+ for track in results ['items' ]:
428+ tracks .append ({'track' : track })
429+
430+ # Handle pagination for albums with more than 50 tracks
431+ while results ['next' ]:
432+ results = sp .next (results )
433+ for track in results ['items' ]:
434+ tracks .append ({'track' : track })
435+ elif content_type == "track" :
436+ print (f"Fetching Spotify track: { content_id } " )
437+
438+ # Get track information
439+ track = sp .track (content_id )
440+
441+ # Add the track to the list in the same format as playlist tracks
442+ tracks .append ({'track' : track })
443+
444+ return tracks
445+
446+
447+ def _search_tracks_on_tidal (ctx : typer .Context , tracks : list ) -> tuple [list , list ]:
448+ """Search for Spotify tracks on TIDAL.
449+
450+ :param ctx: The typer context.
451+ :type ctx: typer.Context
452+ :param tracks: The list of Spotify tracks.
453+ :type tracks: list
454+ :return: A tuple containing lists of found URLs and not found tracks.
455+ :rtype: tuple[list, list]
456+ """
457+ urls = []
458+ not_found = []
459+
460+ for track in tracks :
461+ # Handle different track structures between playlist and album responses
462+ if 'track' in track :
463+ # Playlist track structure
464+ track_info = track ['track' ]
465+ else :
466+ # Album track structure (already at the track level)
467+ track_info = track
468+
469+ artist = track_info ['artists' ][0 ]['name' ]
470+ title = track_info ['name' ]
471+
472+ # Extract ISRC if available
473+ isrc = None
474+ if 'external_ids' in track_info and 'isrc' in track_info ['external_ids' ]:
475+ isrc = track_info ['external_ids' ]['isrc' ]
476+
477+ # Call login method to validate the token
478+ if not ctx .obj [CTX_TIDAL ]:
479+ ctx .invoke (login , ctx )
480+
481+ # First try to find by ISRC if available
482+ found_by_isrc = False
483+ if isrc :
484+ # Search on TIDAL using text search
485+ results = ctx .obj [CTX_TIDAL ].session .search (f"{ artist } { title } " , models = [tidalapi .media .Track ])
486+ if results and len (results ['tracks' ]) > 0 :
487+ # Check if any of the results have a matching ISRC
488+ for tidal_track in results ['tracks' ]:
489+ if hasattr (tidal_track , 'isrc' ) and tidal_track .isrc == isrc :
490+ track_url = tidal_track .share_url
491+ urls .append (track_url )
492+ found_by_isrc = True
493+ print (f"Found exact match by ISRC for: { artist } - { title } " )
494+ break
495+
496+ # If not found by ISRC, fall back to text search
497+ if not isrc or not found_by_isrc :
498+ # Search on TIDAL
499+ results = ctx .obj [CTX_TIDAL ].session .search (f"{ artist } { title } " , models = [tidalapi .media .Track ])
500+ if results and len (results ['tracks' ]) > 0 :
501+ track_url = results ['tracks' ][0 ].share_url
502+ urls .append (track_url )
503+ else :
504+ not_found .append (f"{ artist } - { title } " )
505+
506+ return urls , not_found
507+
508+
509+ @app .command (name = "spotify" )
510+ def download_spotify (
511+ ctx : typer .Context ,
512+ spotify_url : Annotated [str , typer .Argument (help = "Spotify URL (playlist, album, or track)" )], # noqa: UP007
513+ ) -> bool :
514+ """Download tracks from a Spotify playlist, album, or individual track by searching for them on TIDAL.
515+
516+ The matching process first attempts to find tracks by ISRC (International Standard
517+ Recording Code) for exact matching between services. If no match is found by ISRC
518+ or if the ISRC is not available, it falls back to text search using artist and title.
519+
520+ Requires Spotify API credentials to be configured:
521+ 1. Create an app at https://developer.spotify.com/dashboard
522+ 2. Set the client ID: tidal-dl-ng cfg spotify_client_id YOUR_CLIENT_ID
523+ 3. Set the client secret: tidal-dl-ng cfg spotify_client_secret YOUR_CLIENT_SECRET
524+ """
525+ settings = Settings ()
526+
527+ # Validate Spotify credentials
528+ _validate_spotify_credentials (settings )
529+
530+ # Initialize Spotify client
531+ sp = spotipy .Spotify (auth_manager = SpotifyClientCredentials (
532+ client_id = settings .data .spotify_client_id ,
533+ client_secret = settings .data .spotify_client_secret
534+ ))
535+
536+ # Extract ID and type from URL
537+ content_type , content_id = _extract_spotify_id (spotify_url )
538+
539+ # Fetch tracks from Spotify
540+ tracks = _fetch_spotify_tracks (sp , content_type , content_id )
541+ total_tracks = len (tracks )
542+
543+ # Search for tracks on TIDAL
544+ urls , not_found = _search_tracks_on_tidal (ctx , tracks )
545+
546+ # Print summary of found tracks
547+ if urls :
548+ print (f"\n Found { len (urls )} of { total_tracks } tracks on TIDAL" )
549+ else :
550+ print ("\n No tracks found to download" )
551+
552+ # Print not found tracks
553+ if not_found :
554+ print ("\n Songs not found on TIDAL:" )
555+ for song in not_found :
556+ print (song )
557+
558+ # Use the existing download function if we have URLs
559+ if urls :
560+ return _download (ctx , urls , try_login = False )
561+ else :
562+ return False
563+
347564@app .command ()
348565def gui (ctx : typer .Context ):
349566 from tidal_dl_ng .gui import gui_activate
0 commit comments