Skip to content

Commit 3cbb6fc

Browse files
committed
Fix breaking changes from Spotify API February 2026 update
- Rename playlist item key from 'track' to 'item' - Update fields param, use playlist_items instead of playlist_tracks - Deprecate isrc_match - Add unittests
1 parent 8a66f5f commit 3cbb6fc

2 files changed

Lines changed: 159 additions & 12 deletions

File tree

src/spotify_to_tidal/sync.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from .cache import failure_cache, track_match_cache
55
import datetime
66
from difflib import SequenceMatcher
7-
from functools import partial
87
from typing import Callable, List, Sequence, Set, Mapping
98
import math
109
import requests
@@ -17,7 +16,6 @@
1716
from tqdm import tqdm
1817
import traceback
1918
import unicodedata
20-
import math
2119

2220
from .type import spotify as t_spotify
2321

@@ -28,8 +26,11 @@ def simple(input_string: str) -> str:
2826
# only take the first part of a string before any hyphens or brackets to account for different versions
2927
return input_string.split('-')[0].strip().split('(')[0].strip().split('[')[0].strip()
3028

31-
def isrc_match(tidal_track: tidalapi.Track, spotify_track) -> bool:
32-
if "isrc" in spotify_track["external_ids"]:
29+
def deprecated_isrc_match(tidal_track: tidalapi.Track, spotify_track) -> bool:
30+
"""Spotify has deprecated the external_ids attribute and it will not be
31+
returned to clients past 03/09/2026. This method can be removed past this
32+
date."""
33+
if "isrc" in spotify_track.get("external_ids", {}):
3334
return tidal_track.isrc == spotify_track["external_ids"]["isrc"]
3435
return False
3536

@@ -89,7 +90,7 @@ def get_spotify_artists(spotify, do_normalize=False) -> Set[str]:
8990

9091
def match(tidal_track, spotify_track) -> bool:
9192
if not spotify_track['id']: return False
92-
return isrc_match(tidal_track, spotify_track) or (
93+
return deprecated_isrc_match(tidal_track, spotify_track) or (
9394
duration_match(tidal_track, spotify_track)
9495
and name_match(tidal_track, spotify_track)
9596
and artist_match(tidal_track, spotify_track)
@@ -161,7 +162,7 @@ async def repeat_on_request_error(function, *args, remaining=5, **kwargs):
161162
async def _fetch_all_from_spotify_in_chunks(fetch_function: Callable) -> List[dict]:
162163
output = []
163164
results = fetch_function(0)
164-
output.extend([item['track'] for item in results['items'] if item['track'] is not None])
165+
output.extend([item['item'] for item in results['items'] if item['item']['type'] == 'track'])
165166

166167
# Get all the remaining tracks in parallel
167168
if results['next']:
@@ -171,15 +172,15 @@ async def _fetch_all_from_spotify_in_chunks(fetch_function: Callable) -> List[di
171172
desc="Fetching additional data chunks"
172173
)
173174
for extra_result in extra_results:
174-
output.extend([item['track'] for item in extra_result['items'] if item['track'] is not None])
175+
output.extend([item['item'] for item in extra_result['items'] if item['item']['type'] == 'track'])
175176

176177
return output
177178

178179

179180
async def get_tracks_from_spotify_playlist(spotify_session: spotipy.Spotify, spotify_playlist):
180181
def _get_tracks_from_spotify_playlist(offset: int, playlist_id: str):
181-
fields = "next,total,limit,items(track(name,album(name,artists),artists,track_number,duration_ms,id,external_ids(isrc))),type"
182-
return spotify_session.playlist_tracks(playlist_id=playlist_id, fields=fields, offset=offset)
182+
fields = "next,total,limit,items(item(name,album(name,artists),artists,track_number,duration_ms,id,external_ids(isrc))),type"
183+
return spotify_session.playlist_items(playlist_id=playlist_id, fields=fields, offset=offset)
183184

184185
print(f"Loading tracks from Spotify playlist '{spotify_playlist['name']}'")
185186
items = await repeat_on_request_error( _fetch_all_from_spotify_in_chunks, lambda offset: _get_tracks_from_spotify_playlist(offset=offset, playlist_id=spotify_playlist["id"]))
@@ -239,7 +240,7 @@ def get_tracks_for_new_tidal_playlist(spotify_tracks: Sequence[t_spotify.Spotify
239240
if tidal_id in seen_tracks:
240241
track_name = spotify_track['name']
241242
artist_names = ', '.join([artist['name'] for artist in spotify_track['artists']])
242-
print(f'Duplicate found: Track "{track_name}" by {artist_names} will be ignored')
243+
print(f'Duplicate found: Track "{track_name}" by {artist_names} will be ignored')
243244
else:
244245
output.append(tidal_id)
245246
seen_tracks.add(tidal_id)
@@ -287,7 +288,7 @@ async def _run_rate_limiter(semaphore):
287288
for song in song404:
288289
file.write(f"{song}\n")
289290

290-
291+
291292
async def sync_playlist(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, spotify_playlist, tidal_playlist: tidalapi.Playlist | None, config: dict):
292293
""" sync given playlist to tidal """
293294
# Get the tracks from both Spotify and Tidal, creating a new Tidal playlist if necessary
@@ -321,7 +322,7 @@ async def sync_playlist(spotify_session: spotipy.Spotify, tidal_session: tidalap
321322
async def sync_favorites(spotify_session: spotipy.Spotify, tidal_session: tidalapi.Session, config: dict):
322323
""" sync user favorites to tidal """
323324
async def get_tracks_from_spotify_favorites() -> List[dict]:
324-
_get_favorite_tracks = lambda offset: spotify_session.current_user_saved_tracks(offset=offset)
325+
_get_favorite_tracks = lambda offset: spotify_session.current_user_saved_tracks(offset=offset)
325326
tracks = await repeat_on_request_error( _fetch_all_from_spotify_in_chunks, _get_favorite_tracks)
326327
tracks.reverse()
327328
return tracks

tests/unit/test_sync.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from typing import Literal
2+
from unittest.mock import MagicMock
3+
import asyncio
4+
5+
from spotify_to_tidal.sync import deprecated_isrc_match
6+
from spotify_to_tidal.sync import _fetch_all_from_spotify_in_chunks
7+
from spotify_to_tidal.sync import get_tracks_from_spotify_playlist
8+
9+
10+
def _make_tidal_track(isrc: str = 'USAT21234567') -> MagicMock:
11+
track = MagicMock()
12+
track.isrc = isrc
13+
return track
14+
15+
16+
def _make_spotify_track(
17+
isrc: str = 'USAT21234567',
18+
name: str = 'Test Track',
19+
external_ids: bool = True,
20+
) -> dict:
21+
"""Build a minimal Spotify track dict. external_ids are deprecated and will
22+
go away soon."""
23+
track = {
24+
'name': name,
25+
'id': 'spotify123',
26+
'type': 'track',
27+
'duration_ms': 210000,
28+
'track_number': 1,
29+
'artists': [{'name': 'Test Artist'}],
30+
'album': {'name': 'Test Album', 'artists': [{'name': 'Test Artist'}]},
31+
}
32+
if external_ids:
33+
track['external_ids'] = {'isrc': isrc}
34+
return track
35+
36+
def _make_spotify_api_response(items, next_url=None, total=None, limit=50) -> dict:
37+
"""Build a response dict matching Spotify's playlist items endpoint."""
38+
return {
39+
'items': items,
40+
'next': next_url,
41+
'total': total or len(items),
42+
'limit': limit,
43+
}
44+
45+
46+
def _make_playlist_item(track_name: str = 'Test Track', item_type: Literal['track','episode'] = 'track') -> dict:
47+
"""Build a single playlist item."""
48+
return {
49+
'item': {
50+
'name': track_name,
51+
'id': 'spotify123',
52+
'type': item_type,
53+
'duration_ms': 210000,
54+
'track_number': 1,
55+
'artists': [{'name': 'Test Artist'}],
56+
'album': {'name': 'Test Album', 'artists': [{'name': 'Test Artist'}]},
57+
},
58+
'type': item_type,
59+
}
60+
61+
62+
63+
class TestDeprecatedIsrcMatch:
64+
def test_matches_when_isrc_present_and_equal(self) -> None:
65+
tidal = _make_tidal_track(isrc='USAT21234567')
66+
spotify = _make_spotify_track(isrc='USAT21234567')
67+
assert deprecated_isrc_match(tidal, spotify) is True
68+
69+
def test_no_match_when_isrc_differs(self) -> None:
70+
tidal = _make_tidal_track(isrc='USAT21234567')
71+
spotify = _make_spotify_track(isrc='GBDCA0000001')
72+
assert deprecated_isrc_match(tidal, spotify) is False
73+
74+
def test_returns_false_when_external_ids_missing(self) -> None:
75+
tidal = _make_tidal_track()
76+
spotify = _make_spotify_track(external_ids=False)
77+
assert deprecated_isrc_match(tidal, spotify) is False
78+
79+
def test_returns_false_when_isrc_key_missing(self) -> None:
80+
tidal = _make_tidal_track()
81+
spotify = _make_spotify_track()
82+
spotify['external_ids'] = {}
83+
assert deprecated_isrc_match(tidal, spotify) is False
84+
85+
86+
87+
class TestFetchAllFromSpotifyInChunks:
88+
def test_single_page(self) -> None:
89+
items = [_make_playlist_item('Track 1'), _make_playlist_item('Track 2')]
90+
response = _make_spotify_api_response(items)
91+
92+
def fetch_fn(offset: int) -> dict:
93+
return response
94+
95+
result = asyncio.run(_fetch_all_from_spotify_in_chunks(fetch_fn))
96+
assert len(result) == 2
97+
assert result[0]['name'] == 'Track 1'
98+
assert result[1]['name'] == 'Track 2'
99+
100+
def test_multiple_pages(self) -> None:
101+
page1_items = [_make_playlist_item('Track 1')]
102+
page2_items = [_make_playlist_item('Track 2')]
103+
page1 = _make_spotify_api_response(
104+
page1_items, next_url='https://spotify.example.com/next', total=2, limit=1
105+
)
106+
page2 = _make_spotify_api_response(page2_items, total=2, limit=1)
107+
108+
def fetch_fn(offset: int) -> dict:
109+
return page1 if offset == 0 else page2
110+
111+
result = asyncio.run(_fetch_all_from_spotify_in_chunks(fetch_fn))
112+
assert len(result) == 2
113+
assert result[0]['name'] == 'Track 1'
114+
assert result[1]['name'] == 'Track 2'
115+
116+
def test_filters_non_track_items(self) -> None:
117+
items = [
118+
_make_playlist_item('Track 1', item_type='track'),
119+
_make_playlist_item('Episode 1', item_type='episode'),
120+
]
121+
response = _make_spotify_api_response(items)
122+
def fetch_fn(offset: int) -> dict:
123+
return response
124+
125+
result = asyncio.run(_fetch_all_from_spotify_in_chunks(fetch_fn))
126+
assert len(result) == 1
127+
assert result[0]['name'] == 'Track 1'
128+
129+
130+
class TestGetTracksFromSpotifyPlaylist:
131+
def test_filters_out_episodes(self) -> None:
132+
items = [
133+
_make_playlist_item('Track 1', item_type='track'),
134+
_make_playlist_item('Episode 1', item_type='episode'),
135+
]
136+
response = _make_spotify_api_response(items)
137+
mock_session = MagicMock()
138+
mock_session.playlist_items.return_value = response
139+
spotify_playlist = {'name': 'My Playlist', 'id': 'playlist123'}
140+
141+
result = asyncio.run(
142+
get_tracks_from_spotify_playlist(mock_session, spotify_playlist)
143+
)
144+
assert len(result) == 1
145+
assert result[0]['name'] == 'Track 1'
146+

0 commit comments

Comments
 (0)