mopidy-spotify 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ import pathlib
2
+ from importlib.metadata import version
3
+ from typing import override
4
+
5
+ import cyclopts
6
+ from mopidy import config, ext
7
+
8
+ __version__ = version("mopidy-spotify")
9
+
10
+
11
+ class Extension(ext.Extension):
12
+ dist_name = "mopidy-spotify"
13
+ ext_name = "spotify"
14
+ version = __version__
15
+
16
+ @override
17
+ def get_default_config(self) -> str:
18
+ return config.read(pathlib.Path(__file__).parent / "ext.conf")
19
+
20
+ @override
21
+ def get_config_schema(self) -> config.ConfigSchema:
22
+ schema = super().get_config_schema()
23
+
24
+ schema["username"] = config.Deprecated() # since 5.0
25
+ schema["password"] = config.Deprecated() # since 5.0
26
+
27
+ schema["client_id"] = config.String()
28
+ schema["client_secret"] = config.Secret()
29
+
30
+ schema["bitrate"] = config.Integer(choices=(96, 160, 320))
31
+ schema["volume_normalization"] = config.Boolean()
32
+ schema["private_session"] = config.Deprecated() # since 5.0
33
+
34
+ schema["timeout"] = config.Integer(minimum=0)
35
+
36
+ schema["cache_dir"] = config.Deprecated() # since 2.0
37
+ schema["settings_dir"] = config.Deprecated() # since 2.0
38
+
39
+ schema["allow_cache"] = config.Boolean()
40
+ schema["cache_size"] = config.Integer(minimum=0)
41
+
42
+ schema["allow_network"] = config.Deprecated() # since 5.0
43
+ schema["allow_playlists"] = config.Boolean()
44
+
45
+ schema["search_album_count"] = config.Integer(minimum=0, maximum=200)
46
+ schema["search_artist_count"] = config.Integer(minimum=0, maximum=200)
47
+ schema["search_track_count"] = config.Integer(minimum=0, maximum=200)
48
+
49
+ schema["toplist_countries"] = config.Deprecated() # since 5.0
50
+
51
+ return schema
52
+
53
+ @override
54
+ def setup(self, registry: ext.Registry) -> None:
55
+ from mopidy_spotify.backend import SpotifyBackend # noqa: PLC0415
56
+
57
+ registry.add("backend", SpotifyBackend)
58
+
59
+ @override
60
+ def get_command(self) -> cyclopts.App:
61
+ from .commands import app # noqa: PLC0415
62
+
63
+ return app
64
+
65
+ @classmethod
66
+ def get_credentials_dir(cls, config: config.Config) -> pathlib.Path:
67
+ data_dir = cls.get_data_dir(config)
68
+ credentials_dir = data_dir / "credentials-cache"
69
+ credentials_dir.mkdir(mode=0o700, exist_ok=True)
70
+ return credentials_dir
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar, cast, override
4
+
5
+ import pykka
6
+ from mopidy import backend
7
+ from mopidy.types import UriScheme
8
+
9
+ from mopidy_spotify import Extension, library, playlists, web
10
+
11
+ if TYPE_CHECKING:
12
+ from mopidy.audio import AudioProxy
13
+ from mopidy.config import Config
14
+
15
+ from mopidy_spotify.types import SpotifyConfig
16
+
17
+
18
+ class SpotifyBackend(pykka.ThreadingActor, backend.Backend):
19
+ uri_schemes: ClassVar[list[UriScheme]] = [UriScheme("spotify")]
20
+
21
+ def __init__(self, *, config: Config, audio: AudioProxy) -> None:
22
+ super().__init__()
23
+
24
+ self._config = config
25
+ self._audio = audio
26
+ self._bitrate = config["spotify"]["bitrate"]
27
+ self._web_client = None
28
+
29
+ self.library = library.SpotifyLibraryProvider(backend=self)
30
+ self.playback = SpotifyPlaybackProvider(audio=audio, backend=self)
31
+ if config["spotify"]["allow_playlists"]:
32
+ self.playlists = playlists.SpotifyPlaylistsProvider(backend=self)
33
+ else:
34
+ self.playlists = None
35
+
36
+ def on_start(self) -> None:
37
+ self._web_client = web.SpotifyOAuthClient(
38
+ client_id=self._config["spotify"]["client_id"],
39
+ client_secret=self._config["spotify"]["client_secret"],
40
+ proxy_config=self._config["proxy"],
41
+ )
42
+ self._web_client.login()
43
+
44
+ if self.playlists is not None:
45
+ self.playlists.refresh()
46
+
47
+
48
+ class SpotifyPlaybackProvider(backend.PlaybackProvider):
49
+ backend: SpotifyBackend
50
+
51
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
52
+ super().__init__(*args, **kwargs)
53
+ self._cache_location = Extension().get_cache_dir(self.backend._config)
54
+ self._credentials_dir = Extension().get_credentials_dir(self.backend._config)
55
+ self._config = cast("SpotifyConfig", self.backend._config["spotify"])
56
+
57
+ @override
58
+ def on_source_setup(
59
+ self,
60
+ source: Any, # Gst.Element, but want to avoid Gst imports here
61
+ ) -> None:
62
+ assert self.backend._web_client # noqa: S101
63
+ source.set_property("bitrate", str(self._config["bitrate"]))
64
+ source.set_property("cache-credentials", self._credentials_dir)
65
+ source.set_property("access-token", self.backend._web_client.token())
66
+ if self._config["allow_cache"]:
67
+ source.set_property("cache-files", self._cache_location)
68
+ source.set_property("cache-max-size", self._config["cache_size"] * 1048576)
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from mopidy.models import Ref
7
+ from mopidy.types import Uri
8
+
9
+ from mopidy_spotify import playlists, translator
10
+ from mopidy_spotify.utils import flatten
11
+ from mopidy_spotify.web import WebLink
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterator
15
+
16
+ from mopidy_spotify.types import SpotifyConfig
17
+ from mopidy_spotify.web import SpotifyOAuthClient
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ ROOT_DIR = Ref.directory(uri=Uri("spotify:directory"), name="Spotify")
22
+
23
+ _TOP_LIST_DIR = Ref.directory(uri=Uri("spotify:top"), name="Top lists")
24
+ _YOUR_MUSIC_DIR = Ref.directory(uri=Uri("spotify:your"), name="Your music")
25
+ _PLAYLISTS_DIR = Ref.directory(uri=Uri("spotify:playlists"), name="Playlists")
26
+
27
+ _ROOT_DIR_CONTENTS = [
28
+ _TOP_LIST_DIR,
29
+ _YOUR_MUSIC_DIR,
30
+ _PLAYLISTS_DIR,
31
+ ]
32
+
33
+ _TOP_LIST_DIR_CONTENTS = [
34
+ Ref.directory(uri=Uri("spotify:top:tracks"), name="Top tracks"),
35
+ Ref.directory(uri=Uri("spotify:top:artists"), name="Top artists"),
36
+ ]
37
+
38
+ _YOUR_MUSIC_DIR_CONTENTS = [
39
+ Ref.directory(uri=Uri("spotify:your:tracks"), name="Your tracks"),
40
+ Ref.directory(uri=Uri("spotify:your:albums"), name="Your albums"),
41
+ ]
42
+
43
+ _PLAYLISTS_DIR_CONTENTS = [
44
+ Ref.directory(uri=Uri("spotify:playlists:featured"), name="Featured"),
45
+ ]
46
+
47
+
48
+ BROWSE_DIR_URIS = {
49
+ directory.uri
50
+ for directory in [
51
+ ROOT_DIR,
52
+ *_ROOT_DIR_CONTENTS,
53
+ *_TOP_LIST_DIR_CONTENTS,
54
+ *_YOUR_MUSIC_DIR_CONTENTS,
55
+ *_PLAYLISTS_DIR_CONTENTS,
56
+ ]
57
+ }
58
+
59
+
60
+ def browse( # noqa: C901, PLR0911, PLR0912
61
+ *,
62
+ config: SpotifyConfig, # noqa: ARG001
63
+ web_client: SpotifyOAuthClient,
64
+ uri: Uri,
65
+ ) -> list[Ref]:
66
+ if uri == ROOT_DIR.uri:
67
+ return _ROOT_DIR_CONTENTS
68
+ if uri == _TOP_LIST_DIR.uri:
69
+ return _TOP_LIST_DIR_CONTENTS
70
+ if uri == _YOUR_MUSIC_DIR.uri:
71
+ return _YOUR_MUSIC_DIR_CONTENTS
72
+ if uri == _PLAYLISTS_DIR.uri:
73
+ return _PLAYLISTS_DIR_CONTENTS
74
+
75
+ if not web_client.logged_in:
76
+ return []
77
+
78
+ # TODO: Support for category browsing.
79
+ if uri.startswith(("spotify:user:", "spotify:playlist:")):
80
+ return _browse_playlist(web_client, uri)
81
+ if uri.startswith("spotify:album:"):
82
+ return _browse_album(web_client, uri)
83
+ if uri.startswith("spotify:artist:"):
84
+ return _browse_artist(web_client, uri)
85
+ if uri.startswith("spotify:top:"):
86
+ parts = uri.replace("spotify:top:", "").split(":")
87
+ if len(parts) != 1:
88
+ logger.info(f"Failed to browse {uri!r}: Toplist URI parsing failed")
89
+ return []
90
+ return _browse_toplist_user(web_client, variant=parts[0])
91
+ if uri.startswith("spotify:your:"):
92
+ parts = uri.replace("spotify:your:", "").split(":")
93
+ if len(parts) == 1:
94
+ return _browse_your_music(web_client, variant=parts[0])
95
+ if uri.startswith("spotify:playlists:"):
96
+ parts = uri.replace("spotify:playlists:", "").split(":")
97
+ if len(parts) == 1:
98
+ return _browse_playlists(web_client, variant=parts[0])
99
+
100
+ logger.info(f"Failed to browse {uri!r}: Unknown URI type")
101
+ return []
102
+
103
+
104
+ def _browse_playlist(
105
+ web_client: SpotifyOAuthClient,
106
+ uri: Uri,
107
+ ) -> list[Ref]:
108
+ playlist_items = playlists.playlist_lookup(
109
+ web_client,
110
+ uri,
111
+ bitrate=None,
112
+ as_items=True,
113
+ )
114
+ return playlist_items if playlist_items is not None else []
115
+
116
+
117
+ def _browse_album(web_client: SpotifyOAuthClient, uri: Uri) -> list[Ref]:
118
+ try:
119
+ link = WebLink.from_uri(uri)
120
+ except ValueError as exc:
121
+ logger.info(f"Failed to browse {uri!r}: {exc}")
122
+ return []
123
+
124
+ for web_album in web_client.get_albums([link]):
125
+ web_tracks = web_album.get("tracks", {}).get("items", [])
126
+ return list(translator.web_to_track_refs(web_tracks))
127
+ return []
128
+
129
+
130
+ def _browse_artist(web_client: SpotifyOAuthClient, uri: Uri) -> list[Ref]:
131
+ try:
132
+ link = WebLink.from_uri(uri)
133
+ except ValueError as exc:
134
+ logger.info(f"Failed to browse {uri!r}: {exc}")
135
+ return []
136
+
137
+ web_top_tracks = web_client.get_artist_top_tracks(link)
138
+ top_tracks = list(translator.web_to_track_refs(web_top_tracks))
139
+
140
+ web_albums = web_client.get_artist_albums(link, all_tracks=False)
141
+ albums = list(translator.web_to_album_refs(web_albums))
142
+
143
+ return top_tracks + albums
144
+
145
+
146
+ def _browse_toplist_user(web_client: SpotifyOAuthClient, variant: str) -> list[Ref]:
147
+ if not web_client.logged_in:
148
+ return []
149
+
150
+ if variant in ("tracks", "artists"):
151
+ items = flatten(
152
+ [
153
+ page.get("items", [])
154
+ for page in web_client.get_all(
155
+ f"me/top/{variant}",
156
+ params={"limit": 50},
157
+ )
158
+ if page
159
+ ]
160
+ )
161
+ match variant:
162
+ case "tracks":
163
+ return list(translator.web_to_track_refs(items, check_playable=False))
164
+ case "artists":
165
+ return list(translator.web_to_artist_refs(items))
166
+ else:
167
+ return []
168
+
169
+
170
+ def _load_your_music(web_client: SpotifyOAuthClient, variant: str) -> Iterator[Any]:
171
+ if not web_client.logged_in:
172
+ return
173
+
174
+ if variant not in ("tracks", "albums"):
175
+ return
176
+
177
+ results = web_client.get_all(
178
+ f"me/{variant}",
179
+ params={"market": "from_token", "limit": 50},
180
+ )
181
+ for page in results:
182
+ if not page:
183
+ continue
184
+ items = page.get("items", [])
185
+ yield from items
186
+
187
+
188
+ def _browse_your_music(web_client: SpotifyOAuthClient, variant: str) -> list[Ref]:
189
+ items = _load_your_music(web_client, variant)
190
+ match variant:
191
+ case "tracks":
192
+ return list(translator.web_to_track_refs(items))
193
+ case "albums":
194
+ return list(translator.web_to_album_refs(items))
195
+ case _:
196
+ return []
197
+
198
+
199
+ def _browse_playlists(web_client: SpotifyOAuthClient, variant: str) -> list[Ref]:
200
+ if not web_client.logged_in:
201
+ return []
202
+
203
+ if variant != "featured":
204
+ return []
205
+
206
+ items = flatten(
207
+ [
208
+ page.get("playlists", {}).get("items", [])
209
+ for page in web_client.get_all(
210
+ "browse/featured-playlists",
211
+ params={"limit": 50},
212
+ )
213
+ if page
214
+ ]
215
+ )
216
+ return list(translator.to_playlist_refs(items))
@@ -0,0 +1,35 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import cyclopts
6
+ from mopidy.config import Config
7
+
8
+ from mopidy_spotify import Extension
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ app = cyclopts.App(help="Spotify extension commands.")
14
+
15
+
16
+ @app.command(help="Logout from Spotify account.")
17
+ def logout() -> None:
18
+ config = Config.get_global()
19
+ credentials_dir = Extension().get_credentials_dir(config)
20
+ try:
21
+ for root, dirs, files in os.walk(credentials_dir, topdown=False):
22
+ root_path = Path(root)
23
+ for name in files:
24
+ file_path = root_path / name
25
+ file_path.unlink()
26
+ logger.debug(f"Removed file {file_path}")
27
+ for name in dirs:
28
+ dir_path = root_path / name
29
+ dir_path.rmdir()
30
+ logger.debug(f"Removed directory {dir_path}")
31
+ credentials_dir.rmdir()
32
+ except Exception as error: # noqa: BLE001
33
+ logger.warning(f"Failed to logout from Spotify: {error}")
34
+ else:
35
+ logger.info("Logged out from Spotify")
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from mopidy_spotify import search
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Generator
10
+
11
+ from mopidy.models import SearchResult, Track
12
+ from mopidy.types import DistinctField, Query, SearchField
13
+
14
+ from mopidy_spotify.playlists import SpotifyPlaylistsProvider
15
+ from mopidy_spotify.types import SpotifyConfig
16
+ from mopidy_spotify.web import SpotifyOAuthClient
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def get_distinct(
22
+ config: SpotifyConfig,
23
+ playlists: SpotifyPlaylistsProvider,
24
+ web_client: SpotifyOAuthClient,
25
+ field: DistinctField,
26
+ query: Query[SearchField] | None = None,
27
+ ) -> set[str]:
28
+ # To make the returned data as interesting as possible, we limit
29
+ # ourselves to data extracted from the user's playlists when no search
30
+ # query is included.
31
+ # TODO: Perhaps should use tracks from My Music instead?
32
+ if not web_client.logged_in:
33
+ return set()
34
+
35
+ match field:
36
+ case "artist":
37
+ result = _get_distinct_artists(config, playlists, web_client, query)
38
+ case "albumartist":
39
+ result = _get_distinct_albumartists(config, playlists, web_client, query)
40
+ case "album":
41
+ result = _get_distinct_albums(config, playlists, web_client, query)
42
+ case "date":
43
+ result = _get_distinct_dates(config, playlists, web_client, query)
44
+ case _:
45
+ result = set()
46
+
47
+ return {x for x in result if isinstance(x, str)}
48
+
49
+
50
+ def _get_distinct_artists(
51
+ config: SpotifyConfig,
52
+ playlists: SpotifyPlaylistsProvider,
53
+ web_client: SpotifyOAuthClient,
54
+ query: Query[SearchField] | None,
55
+ ) -> set[str]:
56
+ logger.debug(f"Getting distinct artists: {query}")
57
+
58
+ if query:
59
+ search_result = _get_search(config, web_client, query, artist=True)
60
+ return {
61
+ artist.name for artist in search_result.artists if artist.name is not None
62
+ }
63
+
64
+ return {
65
+ artist.name
66
+ for track in _get_playlist_tracks(config, playlists, web_client)
67
+ for artist in track.artists
68
+ if artist.name is not None
69
+ }
70
+
71
+
72
+ def _get_distinct_albumartists(
73
+ config: SpotifyConfig,
74
+ playlists: SpotifyPlaylistsProvider,
75
+ web_client: SpotifyOAuthClient,
76
+ query: Query[SearchField] | None,
77
+ ) -> set[str]:
78
+ logger.debug(f"Getting distinct albumartists: {query}")
79
+
80
+ if query:
81
+ search_result = _get_search(config, web_client, query, album=True)
82
+ return {
83
+ artist.name
84
+ for album in search_result.albums
85
+ for artist in album.artists
86
+ if artist.name is not None
87
+ }
88
+
89
+ return {
90
+ artist.name
91
+ for track in _get_playlist_tracks(config, playlists, web_client)
92
+ if track.album and track.album.artists
93
+ for artist in track.album.artists
94
+ if artist.name is not None
95
+ }
96
+
97
+
98
+ def _get_distinct_albums(
99
+ config: SpotifyConfig,
100
+ playlists: SpotifyPlaylistsProvider,
101
+ web_client: SpotifyOAuthClient,
102
+ query: Query[SearchField] | None,
103
+ ) -> set[str]:
104
+ logger.debug(f"Getting distinct albums: {query}")
105
+
106
+ if query:
107
+ search_result = _get_search(config, web_client, query, album=True)
108
+ return {album.name for album in search_result.albums if album.name is not None}
109
+
110
+ return {
111
+ track.album.name
112
+ for track in _get_playlist_tracks(config, playlists, web_client)
113
+ if track.album and track.album.name is not None
114
+ }
115
+
116
+
117
+ def _get_distinct_dates(
118
+ config: SpotifyConfig,
119
+ playlists: SpotifyPlaylistsProvider,
120
+ web_client: SpotifyOAuthClient,
121
+ query: Query[SearchField] | None,
122
+ ) -> set[str]:
123
+ logger.debug(f"Getting distinct album years: {query}")
124
+
125
+ if query:
126
+ search_result = _get_search(config, web_client, query, album=True)
127
+ return {
128
+ album.date
129
+ for album in search_result.albums
130
+ if album.date not in (None, "0")
131
+ }
132
+
133
+ return {
134
+ f"{track.album.date}"
135
+ for track in _get_playlist_tracks(config, playlists, web_client)
136
+ if track.album and track.album.date not in (None, 0)
137
+ }
138
+
139
+
140
+ def _get_search( # noqa: PLR0913
141
+ config: SpotifyConfig,
142
+ web_client: SpotifyOAuthClient,
143
+ query: Query[SearchField],
144
+ *,
145
+ album: bool = False,
146
+ artist: bool = False,
147
+ track: bool = False,
148
+ ) -> SearchResult:
149
+ types = []
150
+ if album:
151
+ types.append("album")
152
+ if artist:
153
+ types.append("artist")
154
+ if track:
155
+ types.append("track")
156
+
157
+ return search.search(
158
+ config,
159
+ web_client,
160
+ query=query,
161
+ types=types,
162
+ )
163
+
164
+
165
+ def _get_playlist_tracks(
166
+ config: SpotifyConfig,
167
+ playlists: SpotifyPlaylistsProvider,
168
+ web_client: SpotifyOAuthClient, # noqa: ARG001
169
+ ) -> Generator[Track]:
170
+ if not config["allow_playlists"]:
171
+ return
172
+
173
+ for playlist_ref in playlists.as_list():
174
+ playlist = playlists.lookup(playlist_ref.uri)
175
+ if playlist:
176
+ yield from playlist.tracks
@@ -0,0 +1,13 @@
1
+ [spotify]
2
+ enabled = true
3
+ client_id =
4
+ client_secret =
5
+ bitrate = 160
6
+ volume_normalization = true
7
+ timeout = 10
8
+ allow_cache = true
9
+ cache_size = 8192
10
+ allow_playlists = true
11
+ search_album_count = 20
12
+ search_artist_count = 10
13
+ search_track_count = 50
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from mopidy_spotify.browse import BROWSE_DIR_URIS
7
+ from mopidy_spotify.translator import web_to_image
8
+ from mopidy_spotify.utils import group_by_type
9
+ from mopidy_spotify.web import LinkType, WebLink
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterable
13
+
14
+ from mopidy.models import Image
15
+ from mopidy.types import Uri
16
+
17
+ from mopidy_spotify.web import SpotifyOAuthClient
18
+
19
+ SUPPORTED_TYPES = (
20
+ LinkType.TRACK,
21
+ LinkType.ALBUM,
22
+ LinkType.ARTIST,
23
+ LinkType.PLAYLIST,
24
+ )
25
+
26
+ _cache = {} # (type, id) -> [Image(), ...]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def get_images(
32
+ web_client: SpotifyOAuthClient,
33
+ uris: Iterable[Uri],
34
+ ) -> dict[Uri, list[Image]]:
35
+ result: dict[Uri, list[Image]] = {}
36
+ links = (_parse_uri(u) for u in uris)
37
+ for link_type, link_group in group_by_type(links):
38
+ batch = []
39
+ for link in link_group:
40
+ key = _make_cache_key(link)
41
+ if key in _cache:
42
+ result[link.uri] = _cache[key]
43
+ elif link_type == LinkType.PLAYLIST:
44
+ result.update(_process_one(web_client, link))
45
+ else:
46
+ batch.append(link)
47
+ result.update(_process_many(web_client, link_type, batch))
48
+ return result
49
+
50
+
51
+ def _make_cache_key(link: WebLink) -> tuple[LinkType, str | None]:
52
+ return (link.type, link.id)
53
+
54
+
55
+ def _parse_uri(uri: Uri) -> WebLink | None:
56
+ if uri in BROWSE_DIR_URIS:
57
+ return None # These are internal to the extension.
58
+ try:
59
+ link = WebLink.from_uri(uri)
60
+ if link.type not in SUPPORTED_TYPES:
61
+ msg = f"Unsupported image type '{link.type}' in {uri!r}"
62
+ raise ValueError(msg) # noqa: TRY301
63
+ if not link.id:
64
+ msg = "ID missing"
65
+ raise ValueError(msg) # noqa: TRY301
66
+ except Exception as e:
67
+ logger.exception(f"Could not parse {uri!r} as a Spotify URI ({e!s})") # noqa: TRY401
68
+ return None
69
+
70
+ return link
71
+
72
+
73
+ def _process_one(
74
+ web_client: SpotifyOAuthClient,
75
+ link: WebLink,
76
+ ) -> dict[Uri, list[Image]]:
77
+ data = web_client.get(f"{link.type}s/{link.id}")
78
+ key = _make_cache_key(link)
79
+ _cache[key] = [web_to_image(i) for i in data.get("images") or []]
80
+ return {link.uri: _cache[key]}
81
+
82
+
83
+ def _process_many(
84
+ web_client: SpotifyOAuthClient,
85
+ link_type: LinkType,
86
+ links: list[WebLink],
87
+ ) -> dict[Uri, list[Image]]:
88
+ result: dict[Uri, list[Image]] = {}
89
+ if not links:
90
+ return result
91
+
92
+ for link, item in web_client.get_batch(link_type, links):
93
+ key = _make_cache_key(link)
94
+ if link_type == LinkType.TRACK:
95
+ if not (album_item := item.get("album")):
96
+ continue
97
+ if not (album_link := _parse_uri(album_item.get("uri"))):
98
+ continue
99
+ album_key = _make_cache_key(album_link)
100
+ if album_key not in _cache:
101
+ _cache[album_key] = [
102
+ web_to_image(i) for i in album_item.get("images") or []
103
+ ]
104
+ _cache[key] = _cache[album_key]
105
+ else:
106
+ _cache[key] = [web_to_image(i) for i in item.get("images") or []]
107
+ result[link.uri] = _cache[key]
108
+
109
+ return result