Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 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
999286a
Merge branch 'master' into spotify_batch
arsaboo Apr 24, 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
128 changes: 114 additions & 14 deletions beetsplug/spotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ class SpotifyPlugin(
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 @@ def _handle_response(
)
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,105 @@ def _output_match_results(self, results):
"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 [
list(ids[i : i + chunk_size])
for i in range(0, len(ids), chunk_size)
]
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] = {}
for chunk in self._chunked(track_ids, 100):
try:
features_data = self._handle_response(
"get",
self.audio_features_batch_url,
params={"ids": ",".join(chunk)},
)
except AudioFeaturesUnavailableError:
self._disable_audio_features()
break
except APIError as e:
self._log.debug("Spotify API error: {}", e)
continue

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
Comment thread
arsaboo marked this conversation as resolved.
Outdated

return features_by_id

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 +839,28 @@ def _fetch_info(self, items, write, force):
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)
Comment thread
snejus marked this conversation as resolved.
Outdated
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)
)
Comment thread
snejus marked this conversation as resolved.
Outdated

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 +909,7 @@ def track_audio_features(self, track_id: str):
"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
11 changes: 10 additions & 1 deletion docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ For plugin developers
Other changes
~~~~~~~~~~~~~

2.9.0 (April 11, 2026)
- :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API
Comment thread
arsaboo marked this conversation as resolved.
Outdated
requests and deduplicate repeated Spotify track IDs within a run.

2.8.0 (March 28, 2026)
----------------------

Beets now officially supports Python 3.14.
Expand Down Expand Up @@ -155,6 +158,12 @@ For plugin developers
respective multi-valued fields instead (``arrangers``, ``composers``,
``lyricists``, ``remixers``).

Other changes
~~~~~~~~~~~~~

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

2.8.0 (March 28, 2026)
----------------------

Expand Down
Loading
Loading