Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d4d265e
batch spotify requests
arsaboo Mar 31, 2026
2d6711e
lint
arsaboo Mar 31, 2026
913149e
Fix Spotify batch helper typing for mypy
arsaboo Mar 31, 2026
75cfa0d
Address reviewer coments
arsaboo Mar 31, 2026
a65ba38
Merge remote-tracking branch 'upstream/master' into spotify_batch
arsaboo Apr 4, 2026
1540105
Merge remote-tracking branch 'upstream/master' into spotify_batch
arsaboo Apr 8, 2026
72d76f6
Merge upstream/master into spotify_batch branch
arsaboo Apr 14, 2026
cd06e62
Resolve merge conflict in changelog.rst
arsaboo Apr 14, 2026
bd6c737
Merge branch 'master' into spotify_batch
arsaboo Apr 18, 2026
826aaeb
Address reviewer comments
arsaboo Apr 19, 2026
92481b6
Merge branch 'spotify_batch' of https://github.com/arsaboo/beets into…
arsaboo Apr 19, 2026
99b81c5
lint
arsaboo Apr 19, 2026
b8e0296
simplify
arsaboo Apr 19, 2026
ea72d3f
more lint
arsaboo Apr 19, 2026
b42d6ef
Merge branch 'master' into spotify_batch
arsaboo Apr 19, 2026
2c7274f
Address reviewer comments and update related tests
arsaboo Apr 19, 2026
8c7868d
Merge branch 'spotify_batch' of https://github.com/arsaboo/beets into…
arsaboo Apr 19, 2026
7d756d7
Merge branch 'master' into spotify_batch
arsaboo Apr 19, 2026
e0aec44
Merge branch 'master' into spotify_batch
arsaboo Apr 20, 2026
2c0dbb1
fix CI failure
arsaboo Apr 20, 2026
786e79f
Merge branch 'spotify_batch' of https://github.com/arsaboo/beets into…
arsaboo Apr 20, 2026
48d6661
fix CI issues
arsaboo Apr 20, 2026
98d4431
Merge branch 'master' into spotify_batch
arsaboo Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 110 additions & 14 deletions beetsplug/spotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@
open_track_url = "https://open.spotify.com/track/"
search_url = "https://api.spotify.com/v1/search"
album_url = "https://api.spotify.com/v1/albums/"
tracks_url = "https://api.spotify.com/v1/tracks"
track_url = "https://api.spotify.com/v1/tracks/"
audio_features_batch_url = "https://api.spotify.com/v1/audio-features"
audio_features_url = "https://api.spotify.com/v1/audio-features/"
Comment thread
arsaboo marked this conversation as resolved.
Outdated

spotify_audio_features: ClassVar[dict[str, str]] = {
Expand Down Expand Up @@ -259,7 +261,7 @@
)
elif e.response.status_code == 403:
# Check if this is the audio features endpoint
if url.startswith(self.audio_features_url):
if url.startswith(self.audio_features_batch_url):
raise AudioFeaturesUnavailableError(
"Audio features API returned 403 "
"(deprecated or unavailable)"
Expand Down Expand Up @@ -722,11 +724,99 @@
"No {.data_source} tracks found from beets query", self
)

@staticmethod
def _chunked(ids: Sequence[str], chunk_size: int) -> list[list[str]]:
"""Split IDs into deterministic chunks for Spotify batch endpoints."""
return [ids[i : i + chunk_size] for i in range(0, len(ids), chunk_size)]

Check failure on line 730 in beetsplug/spotify.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

List comprehension has incompatible type List[Sequence[str]]; expected List[list[str]]
Comment thread
arsaboo marked this conversation as resolved.
Outdated

def _disable_audio_features(self) -> None:
"""Disable audio features globally and warn only once."""
should_log = False
with self._audio_features_lock:
if self.audio_features_available:
self.audio_features_available = False
should_log = True
if should_log:
self._log.warning(
"Audio features API is unavailable (403 error). "
"Skipping audio features for remaining tracks."
)

def track_info_batch(
Comment thread
arsaboo marked this conversation as resolved.
Outdated
self, track_ids: Sequence[str]
) -> dict[str, tuple[Any, str | None, str | None, str | None]]:
Comment thread
arsaboo marked this conversation as resolved.
Outdated
"""Fetch popularity and external IDs in batches of 50 tracks."""
if not track_ids:
return {}

info_by_id: dict[str, tuple[Any, str | None, str | None, str | None]] = {}
for chunk in self._chunked(track_ids, 50):
track_data = self._handle_response(
"get",
self.tracks_url,
params={"ids": ",".join(chunk)},
)

for idx, track in enumerate(track_data.get("tracks", [])):
if track is None:
continue

external_ids = track.get("external_ids", {})
track_id = track.get("id") or chunk[idx]
info_by_id[track_id] = (
track.get("popularity"),
external_ids.get("isrc"),
external_ids.get("ean"),
external_ids.get("upc"),
)

for track_id in chunk:
info_by_id.setdefault(track_id, (None, None, None, None))

return info_by_id

def track_audio_features_batch(
self, track_ids: Sequence[str]
) -> dict[str, JSONDict]:
Comment thread
arsaboo marked this conversation as resolved.
Outdated
"""Fetch track audio features in batches of 100 tracks."""
if not track_ids:
return {}

with self._audio_features_lock:
if not self.audio_features_available:
return {}

features_by_id: dict[str, JSONDict] = {}
try:
for chunk in self._chunked(track_ids, 100):
features_data = self._handle_response(
"get",
self.audio_features_batch_url,
params={"ids": ",".join(chunk)},
)

for idx, feature_data in enumerate(
features_data.get("audio_features", [])
):
if feature_data is None:
continue
track_id = feature_data.get("id") or chunk[idx]
features_by_id[track_id] = feature_data
return features_by_id
except AudioFeaturesUnavailableError:
self._disable_audio_features()
return {}
except APIError as e:
self._log.debug("Spotify API error: {}", e)
return {}

Comment thread
arsaboo marked this conversation as resolved.
Outdated
def _fetch_info(self, items, write, force):
"""Obtain track information from Spotify."""

self._log.debug("Total {} tracks", len(items))

items_to_update: list[tuple[Any, str]] = []
Comment thread
arsaboo marked this conversation as resolved.
Outdated

for index, item in enumerate(items, start=1):
self._log.info(
"Processing {}/{} tracks - {} ", index, len(items), item
Expand All @@ -743,14 +833,30 @@
self._log.debug("No track_id present for: {}", item)
continue

popularity, isrc, ean, upc = self.track_info(spotify_track_id)
items_to_update.append((item, spotify_track_id))

if not items_to_update:
return

track_ids = [track_id for _, track_id in items_to_update]
unique_track_ids = list(dict.fromkeys(track_ids))
Comment thread
arsaboo marked this conversation as resolved.
Outdated
track_info_by_id = self.track_info_batch(unique_track_ids)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
track_info_by_id = self.track_info_batch(unique_track_ids)
audio_features_by_id = self.track_info_batch(unique_track_ids)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming here would clash with audio_features_by_id already used for track_audio_features_batch. Kept as track_info_by_id for now.

Copy link
Copy Markdown
Member

@snejus snejus Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I renamed the typed dict to TrackDetails so you can have track_details_by_id here, to remove ambiguity regarding beets.hooks.info::TrackInfo.

audio_features_by_id = self.track_audio_features_batch(
unique_track_ids
)

for item, spotify_track_id in items_to_update:
popularity, isrc, ean, upc = track_info_by_id.get(
spotify_track_id, (None, None, None, None)
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, define AudioFeatures as a typed dict and return them from track_info_batch, then you can simplify this logic:

Suggested change
popularity, isrc, ean, upc = track_info_by_id.get(
spotify_track_id, (None, None, None, None)
)
if audio_features := audio_features_by_id(spotify_track_id):
item.update(**audio_features)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't work directly because the SpotifyTrackInfo field names (popularity, isrc, ean, upc) don't match the beets item field names (spotify_track_popularity, isrc, ean, upc), and for audio features the Spotify keys (danceability) also differ from beets fields (spotify_danceability).


item["spotify_track_popularity"] = popularity
item["isrc"] = isrc
item["ean"] = ean
item["upc"] = upc

if self.audio_features_available:
audio_features = self.track_audio_features(spotify_track_id)
audio_features = audio_features_by_id.get(spotify_track_id)
Comment thread
arsaboo marked this conversation as resolved.
if audio_features is None:
self._log.info("No audio features found for: {}", item)
else:
Expand Down Expand Up @@ -799,17 +905,7 @@
"get", f"{self.audio_features_url}{track_id}"
)
except AudioFeaturesUnavailableError:
# Disable globally in a thread-safe manner and warn once.
should_log = False
with self._audio_features_lock:
if self.audio_features_available:
self.audio_features_available = False
should_log = True
if should_log:
self._log.warning(
"Audio features API is unavailable (403 error). "
"Skipping audio features for remaining tracks."
)
self._disable_audio_features()
return None
except APIError as e:
self._log.debug("Spotify API error: {}", e)
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Bug fixes
Other changes
~~~~~~~~~~~~~

- :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API
requests and deduplicate repeated Spotify track IDs within a run.

Comment thread
arsaboo marked this conversation as resolved.
Outdated
2.8.0 (March 28, 2026)
----------------------

Expand Down
Loading
Loading