/
/
/
1"""Tidal music provider implementation."""
2
3from __future__ import annotations
4
5import json
6from datetime import datetime
7from sqlite3 import OperationalError
8from typing import TYPE_CHECKING, Any
9
10from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
11from music_assistant_models.enums import ConfigEntryType, ExternalID, MediaType, ProviderFeature
12from music_assistant_models.errors import LoginFailed, MediaNotFoundError
13from music_assistant_models.media_items import (
14 Album,
15 Artist,
16 BrowseFolder,
17 ItemMapping,
18 MediaItemType,
19 Playlist,
20 RecommendationFolder,
21 SearchResults,
22 Track,
23 UniqueList,
24)
25
26from music_assistant.constants import CONF_ENTRY_UNOFFICIAL_PROVIDER
27from music_assistant.controllers.cache import use_cache
28from music_assistant.models.music_provider import MusicProvider
29from music_assistant.models.recommendation_payload import RecommendationPayloadMixin
30
31from .api_client import TidalAPIClient
32from .auth_manager import TidalAuthManager
33from .constants import (
34 CACHE_CATEGORY_ISRC_MAP,
35 CONF_AUTH_TOKEN,
36 CONF_EXPIRY_TIME,
37 CONF_QUALITY,
38 CONF_REFRESH_TOKEN,
39 CONF_USER_ID,
40 OPEN_API_URL,
41)
42from .library import TidalLibraryManager
43from .media import TidalMediaManager
44from .playlist import TidalPlaylistManager
45from .recommendations import TidalRecommendationManager
46from .streaming import TidalStreamingManager
47
48if TYPE_CHECKING:
49 from collections.abc import AsyncGenerator
50
51 from music_assistant_models.config_entries import ProviderConfig
52 from music_assistant_models.provider import ProviderManifest
53 from music_assistant_models.streamdetails import StreamDetails
54
55 from music_assistant.mass import MusicAssistant
56
57
58SUPPORTED_FEATURES = {
59 ProviderFeature.LIBRARY_ARTISTS,
60 ProviderFeature.LIBRARY_ALBUMS,
61 ProviderFeature.LIBRARY_TRACKS,
62 ProviderFeature.LIBRARY_PLAYLISTS,
63 ProviderFeature.ARTIST_ALBUMS,
64 ProviderFeature.ARTIST_TOPTRACKS,
65 ProviderFeature.SEARCH,
66 ProviderFeature.LIBRARY_ARTISTS_EDIT,
67 ProviderFeature.LIBRARY_ALBUMS_EDIT,
68 ProviderFeature.LIBRARY_TRACKS_EDIT,
69 ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
70 ProviderFeature.PLAYLIST_CREATE,
71 ProviderFeature.SIMILAR_TRACKS,
72 ProviderFeature.SIMILAR_ARTISTS,
73 ProviderFeature.BROWSE,
74 ProviderFeature.PLAYLIST_TRACKS_EDIT,
75 ProviderFeature.RECOMMENDATIONS,
76 ProviderFeature.LYRICS,
77}
78
79
80class TidalProvider(RecommendationPayloadMixin, MusicProvider):
81 """Implementation of a Tidal MusicProvider."""
82
83 def __init__(self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig):
84 """Initialize Tidal provider."""
85 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
86 self.auth = TidalAuthManager(
87 http_session=mass.http_session,
88 config_updater=self._update_auth_config,
89 logger=self.logger,
90 )
91 self.api = TidalAPIClient(self)
92 self.library = TidalLibraryManager(self)
93 self.media = TidalMediaManager(self)
94 self.playlists = TidalPlaylistManager(self)
95 self.recommendations_manager = TidalRecommendationManager(self)
96 self.streaming = TidalStreamingManager(self)
97
98 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
99 """
100 Return the configuration (options) entries for the Tidal provider.
101
102 Authentication runs in the interactive setup flow (see ``setup_flow.py``); the only
103 genuine option configured here is the preferred streaming quality.
104 """
105 return (
106 CONF_ENTRY_UNOFFICIAL_PROVIDER,
107 ConfigEntry(
108 key=CONF_QUALITY,
109 type=ConfigEntryType.STRING,
110 required=True,
111 options=[
112 ConfigValueOption("LOSSLESS"),
113 ConfigValueOption("HI_RES_LOSSLESS"),
114 ],
115 default_value="HI_RES_LOSSLESS",
116 ),
117 )
118
119 async def handle_async_init(self) -> None:
120 """Handle async initialization of the provider."""
121 access_token = self.get_setup_value(CONF_AUTH_TOKEN)
122 refresh_token = self.get_setup_value(CONF_REFRESH_TOKEN)
123 expires_at = self.get_setup_value(CONF_EXPIRY_TIME)
124 user_id = self.get_setup_value(CONF_USER_ID)
125
126 if not access_token or not refresh_token:
127 raise LoginFailed("Missing authentication data")
128
129 if isinstance(expires_at, str) and "T" in expires_at:
130 try:
131 dt = datetime.fromisoformat(expires_at)
132 expires_at = dt.timestamp()
133 self._update_setup_data(CONF_EXPIRY_TIME, expires_at)
134 except ValueError:
135 expires_at = 0
136
137 auth_data = {
138 "access_token": access_token,
139 "refresh_token": refresh_token,
140 "expires_at": expires_at,
141 "userId": user_id,
142 }
143
144 if not await self.auth.initialize(json.dumps(auth_data)):
145 raise LoginFailed("Failed to authenticate with Tidal")
146
147 user_info = await self.api.get("sessions")
148 logged_in_user = await self.get_user(str(user_info.get("userId")))
149 await self.auth.update_user_info(logged_in_user, str(user_info.get("sessionId")))
150
151 async def get_user(self, prov_user_id: str) -> dict[str, Any]:
152 """Get user information."""
153 return await self.api.get(f"users/{prov_user_id}")
154
155 @use_cache(3600 * 24 * 14)
156 async def search(
157 self, search_query: str, media_types: list[MediaType], limit: int = 5
158 ) -> SearchResults:
159 """Perform search on musicprovider."""
160 return await self.media.search(search_query, media_types, limit)
161
162 @use_cache(3600 * 24, allow_expired_cache=True)
163 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
164 """Get similar tracks for given track id."""
165 return await self.media.get_similar_tracks(prov_track_id, limit)
166
167 @use_cache(3600 * 24, allow_expired_cache=True)
168 async def get_similar_artists(self, prov_artist_id: str, limit: int = 25) -> list[Artist]:
169 """Get similar artists for given artist id."""
170 return await self.media.get_similar_artists(prov_artist_id, limit)
171
172 @use_cache(3600 * 24 * 30)
173 async def get_artist(self, prov_artist_id: str) -> Artist:
174 """Get artist details for given artist id."""
175 return await self.media.get_artist(prov_artist_id)
176
177 @use_cache(3600 * 24 * 30)
178 async def get_album(self, prov_album_id: str) -> Album:
179 """Get album details for given album id."""
180 return await self.media.get_album(prov_album_id)
181
182 @use_cache(3600 * 24 * 30)
183 async def get_track(self, prov_track_id: str) -> Track:
184 """Get track details for given track id."""
185 return await self.media.get_track(prov_track_id)
186
187 @use_cache(3600 * 24 * 30)
188 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
189 """Get playlist details for given playlist id."""
190 return await self.media.get_playlist(prov_playlist_id)
191
192 @use_cache(3600 * 24 * 30, allow_expired_cache=True)
193 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
194 """Get album tracks for given album id."""
195 return await self.media.get_album_tracks(prov_album_id)
196
197 @use_cache(3600 * 24 * 7, allow_expired_cache=True)
198 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
199 """Get a list of all albums for the given artist."""
200 return await self.media.get_artist_albums(prov_artist_id)
201
202 @use_cache(3600 * 24 * 7, allow_expired_cache=True)
203 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
204 """Get a list of 10 most popular tracks for the given artist."""
205 return await self.media.get_artist_toptracks(prov_artist_id)
206
207 @use_cache(3600 * 3, allow_expired_cache=True)
208 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
209 """Get playlist tracks."""
210 return await self.media.get_playlist_tracks(prov_playlist_id, page)
211
212 async def get_stream_details(
213 self, item_id: str, media_type: MediaType = MediaType.TRACK
214 ) -> StreamDetails:
215 """Return the content details for the given track when it will be streamed."""
216 return await self.streaming.get_stream_details(item_id)
217
218 def get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping:
219 """Create a generic item mapping."""
220 return ItemMapping(
221 media_type=media_type,
222 item_id=key,
223 provider=self.instance_id,
224 name=name,
225 )
226
227 async def get_library_artists(self) -> AsyncGenerator[Artist]:
228 """Retrieve all library artists from Tidal."""
229 async for item in self.library.get_artists():
230 yield item
231
232 async def get_library_albums(self) -> AsyncGenerator[Album]:
233 """Retrieve all library albums from Tidal."""
234 async for item in self.library.get_albums():
235 yield item
236
237 async def get_library_tracks(self) -> AsyncGenerator[Track]:
238 """Retrieve library tracks from Tidal."""
239 async for item in self.library.get_tracks():
240 yield item
241
242 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
243 """Retrieve all library playlists from the provider."""
244 async for item in self.library.get_playlists():
245 yield item
246
247 async def library_add(self, item: MediaItemType) -> bool:
248 """Add item to library."""
249 return await self.library.add_item(item)
250
251 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
252 """Remove item from library."""
253 return await self.library.remove_item(prov_item_id, media_type)
254
255 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
256 """Create a new playlist on provider with given name."""
257 return await self.playlists.create(name)
258
259 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
260 """Add track(s) to playlist."""
261 await self.playlists.add_tracks(prov_playlist_id, prov_track_ids)
262
263 async def remove_playlist_tracks(
264 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
265 ) -> None:
266 """Remove track(s) from playlist."""
267 await self.playlists.remove_tracks(prov_playlist_id, positions_to_remove)
268
269 async def get_recommendations(self) -> list[RecommendationFolder]:
270 """Get this provider's available recommendation rows, without items."""
271 return await self._recommendation_rows_from_payload()
272
273 async def get_recommendation_items(
274 self, item_id: str
275 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
276 """
277 Get the items for a single recommendation row.
278
279 :param item_id: The item_id of the row, as returned by get_recommendations.
280 """
281 return await self._recommendation_items_from_payload(item_id)
282
283 async def redirect_cached_id(self, item_id: str) -> str:
284 """
285 Redirect a (possibly stale) track id to its cached live id, if any.
286
287 This is a cheap, cache-only lookup with no network calls, intended for
288 preemptively redirecting ids when building write batches. Use
289 :meth:`resolve_live_track_id` when a reactive, healing lookup is needed.
290
291 :param item_id: The provider track id to look up.
292 """
293 cached_id = await self.mass.cache.get(
294 item_id, provider=self.instance_id, category=CACHE_CATEGORY_ISRC_MAP
295 )
296 return cached_id or item_id
297
298 def note_replaced_track(self, item: dict[str, Any]) -> None:
299 """
300 Record a replacement Tidal already resolved for us on a read.
301
302 Reads that pass `replaceMedia` come back with the live id plus the id it
303 replaced, which is the same (stale -> live) pair the ISRC resolver works
304 to reconstruct. Taking it from the response caches the redirect and heals
305 the library mapping without a single extra request.
306
307 :param item: A resource identifier from a replaceMedia-projected read.
308 """
309 replacement = (item.get("meta") or {}).get("replacement") or {}
310 if replacement.get("status") != "REPLACED":
311 return
312 stale_id = str((replacement.get("original") or {}).get("id") or "")
313 live_id = str(item.get("id") or "")
314 if not stale_id or not live_id or stale_id == live_id:
315 return
316 # The same replaced track resurfaces on every collection walk; the task id
317 # dedups the (idempotent) heal so concurrent walks don't schedule copies.
318 self.mass.create_task(
319 self._apply_replacement(stale_id, live_id),
320 task_id=f"tidal_heal_{self.instance_id}_{stale_id}",
321 )
322
323 async def resolve_live_track_id(self, item_id: str) -> str | None:
324 """
325 Resolve a possibly-stale track id to its live id via ISRC, healing the library DB.
326
327 Tidal frequently deletes and re-adds tracks under new ids, so a stored
328 track id can go dead. This looks up the track's ISRC and finds the
329 current live id on Tidal, caching the redirect and scheduling a
330 best-effort DB heal of the library's provider mapping.
331
332 :param item_id: The (possibly stale) provider track id to resolve.
333 :return: The live track id if it differs from `item_id`, else `None`.
334 """
335 if cached_id := await self.mass.cache.get(
336 item_id, provider=self.instance_id, category=CACHE_CATEGORY_ISRC_MAP
337 ):
338 if cached_id == item_id:
339 return None
340 # Liveness-check the cached redirect via the UNCACHED media manager: the
341 # cached get_track would keep serving a redirect target that has itself
342 # churned (for up to its full TTL), making every resolve of this id
343 # return a dead track. This path only runs on failures, so the extra
344 # request is rare; a dead target drops the entry and re-resolves below.
345 try:
346 await self.media.get_track(cached_id)
347 except MediaNotFoundError:
348 await self.mass.cache.delete(
349 item_id, provider=self.instance_id, category=CACHE_CATEGORY_ISRC_MAP
350 )
351 else:
352 return str(cached_id)
353
354 lib_track = await self.mass.music.tracks.get_library_item_by_prov_id(
355 item_id, self.instance_id
356 )
357 if not lib_track:
358 return None
359
360 isrc = next((x[1] for x in lib_track.external_ids if x[0] == ExternalID.ISRC), None)
361 if not isrc:
362 return None
363
364 data = await self.api.get("tracks", params={"filter[isrc]": isrc}, base_url=OPEN_API_URL)
365 items = data.get("data", [])
366 if not items:
367 return None
368
369 live_id = str(items[0]["id"])
370 if live_id == item_id:
371 return None
372
373 await self.mass.cache.set(
374 key=item_id,
375 data=live_id,
376 provider=self.instance_id,
377 category=CACHE_CATEGORY_ISRC_MAP,
378 persistent=True,
379 expiration=86400 * 90,
380 )
381
382 self.mass.create_task(
383 self._heal_track_mapping(lib_track.item_id, item_id, live_id),
384 task_id=f"tidal_heal_{self.instance_id}_{item_id}",
385 )
386
387 return live_id
388
389 async def _apply_replacement(self, stale_id: str, live_id: str) -> None:
390 """Cache a Tidal-supplied redirect and heal the library mapping behind it."""
391 await self.mass.cache.set(
392 key=stale_id,
393 data=live_id,
394 provider=self.instance_id,
395 category=CACHE_CATEGORY_ISRC_MAP,
396 persistent=True,
397 expiration=86400 * 90,
398 )
399 lib_track = await self.mass.music.tracks.get_library_item_by_prov_id(
400 stale_id, self.instance_id
401 )
402 if lib_track:
403 await self._heal_track_mapping(lib_track.item_id, stale_id, live_id)
404
405 async def _fetch_recommendation_payload(self) -> list[RecommendationFolder]:
406 """Fetch and parse the full recommendations payload (folders WITH items)."""
407 return await self.recommendations_manager.get_recommendations()
408
409 def _update_auth_config(self, auth_info: dict[str, Any]) -> None:
410 """Update the persisted auth setup data with new (rotated) auth info."""
411 self._update_setup_data(CONF_AUTH_TOKEN, auth_info["access_token"])
412 self._update_setup_data(CONF_REFRESH_TOKEN, auth_info["refresh_token"])
413 self._update_setup_data(CONF_EXPIRY_TIME, auth_info["expires_at"])
414 self._update_setup_data(CONF_USER_ID, auth_info["userId"])
415
416 async def _heal_track_mapping(self, db_item_id: str | int, stale_id: str, live_id: str) -> None:
417 """Best-effort heal of a stale Tidal provider mapping on a library track."""
418 try:
419 live_track = await self.get_track(live_id)
420 new_mapping = next(
421 (
422 m
423 for m in live_track.provider_mappings
424 if m.provider_instance == self.instance_id
425 ),
426 None,
427 )
428 if new_mapping is None:
429 return
430
431 await self.mass.music.tracks.add_provider_mappings(db_item_id, [new_mapping])
432 await self.mass.music.tracks.remove_provider_mapping(
433 db_item_id, self.instance_id, stale_id
434 )
435 self.logger.debug("Healed stale Tidal track mapping %s -> %s", stale_id, live_id)
436 except (MediaNotFoundError, OperationalError, AssertionError) as err:
437 self.logger.debug(
438 "Failed to heal stale Tidal track mapping %s -> %s: %s",
439 stale_id,
440 live_id,
441 err,
442 )
443