diff --git a/README.md b/README.md index 3444875..0f2a340 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,17 @@ $ tidal-dl-ng --help │ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Commands ───────────────────────────────────────────────────────────────────╮ -│ cfg Print or set an option. If no arguments are given, all options will │ -│ be listed. If only one argument is given, the value will be printed │ -│ for this option. To set a value for an option simply pass the value │ -│ as the second argument │ +│ cfg Print or set an option. If no arguments are given, all options will │ +│ be listed. If only one argument is given, the value will be printed │ +│ for this option. To set a value for an option simply pass the value │ +│ as the second argument │ │ dl │ -│ dl_fav Download from a favorites collection. │ +│ dl_fav Download from a favorites collection. │ │ gui │ │ login │ │ logout │ +│ spotify Download tracks from a Spotify playlist, album, or track by │ +│ searching for them on TIDAL. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -69,7 +71,13 @@ tidal-dl-ng dl_fav albums tidal-dl-ng dl_fav videos ``` -You can also use the GUI: +You can also import content from Spotify (see the Spotify Import section below for details): + +```bash +tidal-dl-ng spotify https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M +``` + +And you can use the GUI: ```bash tidal-dl-ng-gui @@ -85,6 +93,7 @@ If you like to have the GUI version only as a binary, have a look at the ## 🧁 Features - Download tracks, videos, albums, playlists, your favorites etc. +- Import Spotify playlists, albums, and tracks with ISRC-based track matching - Multithreaded and multi-chunked downloads - Metadata for songs - Adjustable audio and video download quality. @@ -93,6 +102,41 @@ If you like to have the GUI version only as a binary, have a look at the - Creates playlist files - Can symlink tracks instead of having several copies, if added to different playlist +## 🎵 Spotify Import + +You can import playlists, albums, and individual tracks from Spotify and download them from TIDAL: + +```bash +# Import a playlist +tidal-dl-ng spotify https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M +# Import an album +tidal-dl-ng spotify https://open.spotify.com/album/1DFixLWuPkv3KT3TnV35m3 +# Import a single track +tidal-dl-ng spotify https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT +``` + +### Setting up Spotify API access + +To use the Spotify import feature, you need to set up Spotify API credentials: + +1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) and log in with your Spotify account +2. Click "Create app" +3. Fill in the required fields: + - App name: (any name, e.g., "TIDAL Downloader") + - App description: (any description) + - Redirect URI: http://localhost:8888/callback (this is not actually used but required) + - Select the appropriate checkboxes and click "Save" +4. After creating the app, you'll see your Client ID on the dashboard +5. Click "Show client secret" to reveal your Client Secret +6. Configure these credentials in tidal-dl-ng: + +```bash +tidal-dl-ng cfg spotify_client_id YOUR_CLIENT_ID +tidal-dl-ng cfg spotify_client_secret YOUR_CLIENT_SECRET +``` + +Once configured, you can import content from Spotify. The import process first attempts to match tracks by ISRC (International Standard Recording Code) for exact matching between services, then falls back to text search if needed. + ## ▶️ Getting started with development ### 🚰 Install dependencies diff --git a/pyproject.toml b/pyproject.toml index c231720..69f072e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ typer = "^0.15.1" tidalapi = "^0.8.3" python-ffmpeg = "^2.0.12" pycryptodome = "^3.21.0" +spotipy = "^2.23.0" [project.optional-dependencies] gui = ["pyside6", "pyqtdarktheme-fork"] diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py index f99132e..4570da8 100644 --- a/tidal_dl_ng/cli.py +++ b/tidal_dl_ng/cli.py @@ -2,7 +2,10 @@ from collections.abc import Callable from pathlib import Path from typing import Annotated, Optional - +import re +import spotipy +import tidalapi +from spotipy.oauth2 import SpotifyClientCredentials import typer from rich.live import Live from rich.panel import Panel @@ -57,7 +60,9 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo # Create initial objects. settings: Settings = Settings() - progress: Progress = Progress( + + # Create a single persistent progress display + progress = Progress( "{task.description}", SpinnerColumn(), BarColumn(), @@ -65,8 +70,9 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo refresh_per_second=20, auto_refresh=True, expand=True, - transient=False, # Prevent progress from disappearing + transient=False # Prevent progress from disappearing ) + fn_logger = LoggerWrapped(progress.print) dl = Download( session=ctx.obj[CTX_TIDAL].session, @@ -75,15 +81,18 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo fn_logger=fn_logger, progress=progress, ) - progress_table = Table.grid() - # Style Progress display. - progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2))) + progress_table = Table.grid() + progress_table.add_row( + Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2)) + ) urls_pos_last = len(urls) - 1 - # Use a single Live display for both progress and table - with Live(progress_table, refresh_per_second=20): + # Start the progress display + progress.start() + + try: for item in urls: media_type: MediaType | bool = False @@ -94,7 +103,6 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo file_template = get_format_template(media_type, settings) else: print(f"It seems like that you have supplied an invalid URL: {item}") - continue # Download media. @@ -122,11 +130,11 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo video_download=ctx.obj[CTX_TIDAL].settings.data.video_download, download_delay=settings.data.download_delay, ) - - # Clear and stop progress display - progress.refresh() - progress.stop() - print("\nDownload completed!") + finally: + # Clear and stop progress display + progress.refresh() + progress.stop() + print("\nDownloads completed!") return True @@ -344,6 +352,215 @@ def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool: return _download(ctx, media_urls, try_login=False) +def _validate_spotify_credentials(settings: Settings) -> None: + """Validate that Spotify API credentials are configured. + + :param settings: The application settings. + :type settings: Settings + :raises typer.Exit: If credentials are not configured. + """ + if not settings.data.spotify_client_id or not settings.data.spotify_client_secret: + print("Please set Spotify API credentials in config using:") + print("tidal-dl-ng cfg spotify_client_id YOUR_CLIENT_ID") + print("tidal-dl-ng cfg spotify_client_secret YOUR_CLIENT_SECRET") + raise typer.Exit(1) + + +def _extract_spotify_id(spotify_url: str) -> tuple[str, str]: + """Extract ID and type from a Spotify URL. + + :param spotify_url: The Spotify URL to parse. + :type spotify_url: str + :return: A tuple containing the content type and ID. + :rtype: tuple[str, str] + :raises typer.Exit: If the URL is invalid. + """ + playlist_match = re.search(r'playlist/([a-zA-Z0-9]+)', spotify_url) + album_match = re.search(r'album/([a-zA-Z0-9]+)', spotify_url) + track_match = re.search(r'track/([a-zA-Z0-9]+)', spotify_url) + + if playlist_match: + return "playlist", playlist_match.group(1) + elif album_match: + return "album", album_match.group(1) + elif track_match: + return "track", track_match.group(1) + else: + print("Invalid Spotify URL. Please provide a valid Spotify playlist, album, or track URL.") + raise typer.Exit(1) + + +def _fetch_spotify_tracks(sp: spotipy.Spotify, content_type: str, content_id: str) -> list: + """Fetch tracks from Spotify based on content type and ID. + + :param sp: The Spotify client. + :type sp: spotipy.Spotify + :param content_type: The type of content ('playlist', 'album', or 'track'). + :type content_type: str + :param content_id: The Spotify ID of the content. + :type content_id: str + :return: A list of tracks. + :rtype: list + """ + tracks = [] + + if content_type == "playlist": + print(f"Fetching Spotify playlist: {content_id}") + + # Get all playlist tracks with pagination + results = sp.playlist_tracks(content_id) + tracks.extend(results['items']) + + while results['next']: + results = sp.next(results) + tracks.extend(results['items']) + elif content_type == "album": + print(f"Fetching Spotify album: {content_id}") + + # Get album information + album = sp.album(content_id) + + # Get all album tracks with pagination + results = sp.album_tracks(content_id) + + # Convert album tracks to the same format as playlist tracks + for track in results['items']: + tracks.append({'track': track}) + + # Handle pagination for albums with more than 50 tracks + while results['next']: + results = sp.next(results) + for track in results['items']: + tracks.append({'track': track}) + elif content_type == "track": + print(f"Fetching Spotify track: {content_id}") + + # Get track information + track = sp.track(content_id) + + # Add the track to the list in the same format as playlist tracks + tracks.append({'track': track}) + + return tracks + + +def _search_tracks_on_tidal(ctx: typer.Context, tracks: list) -> tuple[list, list]: + """Search for Spotify tracks on TIDAL. + + :param ctx: The typer context. + :type ctx: typer.Context + :param tracks: The list of Spotify tracks. + :type tracks: list + :return: A tuple containing lists of found URLs and not found tracks. + :rtype: tuple[list, list] + """ + urls = [] + not_found = [] + + for track in tracks: + # Handle different track structures between playlist and album responses + if 'track' in track: + # Playlist track structure + track_info = track['track'] + else: + # Album track structure (already at the track level) + track_info = track + + artist = track_info['artists'][0]['name'] + title = track_info['name'] + + # Extract ISRC if available + isrc = None + if 'external_ids' in track_info and 'isrc' in track_info['external_ids']: + isrc = track_info['external_ids']['isrc'] + + # Call login method to validate the token + if not ctx.obj[CTX_TIDAL]: + ctx.invoke(login, ctx) + + # First try to find by ISRC if available + found_by_isrc = False + if isrc: + # Search on TIDAL using text search + results = ctx.obj[CTX_TIDAL].session.search(f"{artist} {title}", models=[tidalapi.media.Track]) + if results and len(results['tracks']) > 0: + # Check if any of the results have a matching ISRC + for tidal_track in results['tracks']: + if hasattr(tidal_track, 'isrc') and tidal_track.isrc == isrc: + track_url = tidal_track.share_url + urls.append(track_url) + found_by_isrc = True + print(f"Found exact match by ISRC for: {artist} - {title}") + break + + # If not found by ISRC, fall back to text search + if not isrc or not found_by_isrc: + # Search on TIDAL + results = ctx.obj[CTX_TIDAL].session.search(f"{artist} {title}", models=[tidalapi.media.Track]) + if results and len(results['tracks']) > 0: + track_url = results['tracks'][0].share_url + urls.append(track_url) + else: + not_found.append(f"{artist} - {title}") + + return urls, not_found + + +@app.command(name="spotify") +def download_spotify( + ctx: typer.Context, + spotify_url: Annotated[str, typer.Argument(help="Spotify URL (playlist, album, or track)")], # noqa: UP007 +) -> bool: + """Download tracks from a Spotify playlist, album, or individual track by searching for them on TIDAL. + + The matching process first attempts to find tracks by ISRC (International Standard + Recording Code) for exact matching between services. If no match is found by ISRC + or if the ISRC is not available, it falls back to text search using artist and title. + + Requires Spotify API credentials to be configured: + 1. Create an app at https://developer.spotify.com/dashboard + 2. Set the client ID: tidal-dl-ng cfg spotify_client_id YOUR_CLIENT_ID + 3. Set the client secret: tidal-dl-ng cfg spotify_client_secret YOUR_CLIENT_SECRET + """ + settings = Settings() + + # Validate Spotify credentials + _validate_spotify_credentials(settings) + + # Initialize Spotify client + sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials( + client_id=settings.data.spotify_client_id, + client_secret=settings.data.spotify_client_secret + )) + + # Extract ID and type from URL + content_type, content_id = _extract_spotify_id(spotify_url) + + # Fetch tracks from Spotify + tracks = _fetch_spotify_tracks(sp, content_type, content_id) + total_tracks = len(tracks) + + # Search for tracks on TIDAL + urls, not_found = _search_tracks_on_tidal(ctx, tracks) + + # Print summary of found tracks + if urls: + print(f"\nFound {len(urls)} of {total_tracks} tracks on TIDAL") + else: + print("\nNo tracks found to download") + + # Print not found tracks + if not_found: + print("\nSongs not found on TIDAL:") + for song in not_found: + print(song) + + # Use the existing download function if we have URLs + if urls: + return _download(ctx, urls, try_login=False) + else: + return False + @app.command() def gui(ctx: typer.Context): from tidal_dl_ng.gui import gui_activate diff --git a/tidal_dl_ng/model/cfg.py b/tidal_dl_ng/model/cfg.py index 69c394d..047b9d0 100644 --- a/tidal_dl_ng/model/cfg.py +++ b/tidal_dl_ng/model/cfg.py @@ -45,6 +45,8 @@ class Settings: symlink_to_track: bool = False playlist_create: bool = False metadata_replay_gain: bool = True + spotify_client_id: str = "" + spotify_client_secret: str = "" @dataclass_json @@ -99,6 +101,8 @@ class HelpSettings: ) playlist_create: str = "Creates a '_playlist.m3u8' file for downloaded albums, playlists and mixes." metadata_replay_gain: str = "Replay gain information will be written to metadata." + spotify_client_id: str = "Spotify API client ID for importing Spotify playlists. Create an app at https://developer.spotify.com/dashboard" + spotify_client_secret: str = "Spotify API client secret for importing Spotify playlists. Create an app at https://developer.spotify.com/dashboard" @dataclass_json