Skip to content

Commit 1dde9ee

Browse files
committed
feat: Add Spotify playlist import functionality
- Add spotify_client_id and spotify_client_secret config options - Implement spotify command to import playlists from Spotify to TIDAL - Add pagination support to fetch all tracks from large Spotify playlists - Show helpful configuration instructions if credentials missing - Add spotipy dependency for Spotify API access - Improve progress display with single progress bar for all downloads - Skip printing individual track progress messages during spotify download - Show summary of tracks found/not found on TIDAL The spotify command allows importing Spotify playlists by searching for matching tracks on TIDAL. Requires configuring Spotify API credentials: tidal-dl-ng cfg spotify_client_id YOUR_CLIENT_ID tidal-dl-ng cfg spotify_client_secret YOUR_CLIENT_SECRET Usage: tidal-dl-ng spotify "spotify-playlist-url" Signed-off-by: Harvey Lynden
1 parent 56aff8f commit 1dde9ee

File tree

4 files changed

+287
-21
lines changed

4 files changed

+287
-21
lines changed

README.md

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,17 @@ $ tidal-dl-ng --help
2323
│ --help -h Show this message and exit. │
2424
╰──────────────────────────────────────────────────────────────────────────────╯
2525
╭─ Commands ───────────────────────────────────────────────────────────────────╮
26-
│ cfg Print or set an option. If no arguments are given, all options will │
27-
│ be listed. If only one argument is given, the value will be printed │
28-
for this option. To set a value for an option simply pass the value │
29-
│ as the second argument │
26+
│ cfg Print or set an option. If no arguments are given, all options will │
27+
be listed. If only one argument is given, the value will be printed │
28+
for this option. To set a value for an option simply pass the value │
29+
as the second argument │
3030
│ dl │
31-
│ dl_fav Download from a favorites collection. │
32-
│ gui │
31+
│ dl_fav Download from a favorites collection. │
32+
│ gui
3333
│ login │
3434
logout
35+
│ spotify Download tracks from a Spotify playlist, album, or track by │
36+
│ searching for them on TIDAL. │
3537
╰──────────────────────────────────────────────────────────────────────────────╯
3638
```
3739

@@ -69,7 +71,13 @@ tidal-dl-ng dl_fav albums
6971
tidal-dl-ng dl_fav videos
7072
```
7173

72-
You can also use the GUI:
74+
You can also import content from Spotify (see the Spotify Import section below for details):
75+
76+
```bash
77+
tidal-dl-ng spotify https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
78+
```
79+
80+
And you can use the GUI:
7381

7482
```bash
7583
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
8593
## 🧁 Features
8694

8795
- Download tracks, videos, albums, playlists, your favorites etc.
96+
- Import Spotify playlists, albums, and tracks with ISRC-based track matching
8897
- Multithreaded and multi-chunked downloads
8998
- Metadata for songs
9099
- 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
93102
- Creates playlist files
94103
- Can symlink tracks instead of having several copies, if added to different playlist
95104

105+
## 🎵 Spotify Import
106+
107+
You can import playlists, albums, and individual tracks from Spotify and download them from TIDAL:
108+
109+
```bash
110+
# Import a playlist
111+
tidal-dl-ng spotify https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
112+
# Import an album
113+
tidal-dl-ng spotify https://open.spotify.com/album/1DFixLWuPkv3KT3TnV35m3
114+
# Import a single track
115+
tidal-dl-ng spotify https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT
116+
```
117+
118+
### Setting up Spotify API access
119+
120+
To use the Spotify import feature, you need to set up Spotify API credentials:
121+
122+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/) and log in with your Spotify account
123+
2. Click "Create app"
124+
3. Fill in the required fields:
125+
- App name: (any name, e.g., "TIDAL Downloader")
126+
- App description: (any description)
127+
- Redirect URI: http://localhost:8888/callback (this is not actually used but required)
128+
- Select the appropriate checkboxes and click "Save"
129+
4. After creating the app, you'll see your Client ID on the dashboard
130+
5. Click "Show client secret" to reveal your Client Secret
131+
6. Configure these credentials in tidal-dl-ng:
132+
133+
```bash
134+
tidal-dl-ng cfg spotify_client_id YOUR_CLIENT_ID
135+
tidal-dl-ng cfg spotify_client_secret YOUR_CLIENT_SECRET
136+
```
137+
138+
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.
139+
96140
## ▶️ Getting started with development
97141

98142
### 🚰 Install dependencies

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ typer = "^0.15.1"
4343
tidalapi = "^0.8.3"
4444
python-ffmpeg = "^2.0.12"
4545
pycryptodome = "^3.21.0"
46+
spotipy = "^2.23.0"
4647

4748
[project.optional-dependencies]
4849
gui = ["pyside6", "pyqtdarktheme-fork"]

tidal_dl_ng/cli.py

Lines changed: 231 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
from collections.abc import Callable
33
from pathlib import Path
44
from typing import Annotated, Optional
5-
5+
import re
6+
import spotipy
7+
import tidalapi
8+
from spotipy.oauth2 import SpotifyClientCredentials
69
import typer
710
from rich.live import Live
811
from 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("\nDownload completed!")
133+
finally:
134+
# Clear and stop progress display
135+
progress.refresh()
136+
progress.stop()
137+
print("\nDownloads 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"\nFound {len(urls)} of {total_tracks} tracks on TIDAL")
549+
else:
550+
print("\nNo tracks found to download")
551+
552+
# Print not found tracks
553+
if not_found:
554+
print("\nSongs 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()
348565
def gui(ctx: typer.Context):
349566
from tidal_dl_ng.gui import gui_activate

0 commit comments

Comments
 (0)