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.
- mopidy_spotify/__init__.py +70 -0
- mopidy_spotify/backend.py +68 -0
- mopidy_spotify/browse.py +216 -0
- mopidy_spotify/commands.py +35 -0
- mopidy_spotify/distinct.py +176 -0
- mopidy_spotify/ext.conf +13 -0
- mopidy_spotify/images.py +109 -0
- mopidy_spotify/library.py +83 -0
- mopidy_spotify/lookup.py +191 -0
- mopidy_spotify/playlists.py +168 -0
- mopidy_spotify/search.py +136 -0
- mopidy_spotify/translator.py +342 -0
- mopidy_spotify/types.py +16 -0
- mopidy_spotify/utils.py +59 -0
- mopidy_spotify/web.py +735 -0
- mopidy_spotify-5.0.0.dist-info/METADATA +221 -0
- mopidy_spotify-5.0.0.dist-info/RECORD +21 -0
- mopidy_spotify-5.0.0.dist-info/WHEEL +5 -0
- mopidy_spotify-5.0.0.dist-info/entry_points.txt +2 -0
- mopidy_spotify-5.0.0.dist-info/licenses/LICENSE +202 -0
- mopidy_spotify-5.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
mopidy_spotify/browse.py
ADDED
|
@@ -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
|
mopidy_spotify/ext.conf
ADDED
|
@@ -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
|
mopidy_spotify/images.py
ADDED
|
@@ -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
|