/
/
/
1"""Library management for Tidal."""
2
3from __future__ import annotations
4
5from contextlib import suppress
6from datetime import datetime
7from typing import TYPE_CHECKING, Any
8
9from aiohttp.client_exceptions import ClientError
10from music_assistant_models.enums import MediaType
11from music_assistant_models.errors import MediaNotFoundError, ResourceTemporarilyUnavailable
12
13from .constants import SKIPPABLE_ITEM_ERRORS
14from .parsers import parse_favorite_tracks_playlist
15from .parsers_v2 import (
16 _parse_or_skip,
17)
18from .parsers_v2 import (
19 parse_album as parse_album_v2,
20)
21from .parsers_v2 import (
22 parse_artist as parse_artist_v2,
23)
24from .parsers_v2 import (
25 parse_playlist as parse_playlist_v2,
26)
27from .parsers_v2 import (
28 parse_track as parse_track_v2,
29)
30
31# MediaType -> (official collection resource, JSON:API resource type).
32_COLLECTIONS = {
33 MediaType.ARTIST: ("userCollectionArtists", "artists"),
34 MediaType.ALBUM: ("userCollectionAlbums", "albums"),
35 MediaType.TRACK: ("userCollectionTracks", "tracks"),
36 MediaType.PLAYLIST: ("userCollectionPlaylists", "playlists"),
37}
38
39# Errors treated as a failed (best-effort) collection write.
40_WRITE_ERRORS = (ClientError, MediaNotFoundError, ResourceTemporarilyUnavailable)
41
42if TYPE_CHECKING:
43 from collections.abc import AsyncGenerator
44
45 from music_assistant_models.media_items import (
46 Album,
47 Artist,
48 MediaItemType,
49 Playlist,
50 Track,
51 )
52
53 from .jsonapi import JsonApiDocument
54 from .provider import TidalProvider
55
56
57class TidalLibraryManager:
58 """Manages Tidal library operations."""
59
60 def __init__(self, provider: TidalProvider):
61 """Initialize library manager."""
62 self.provider = provider
63 self.api = provider.api
64 self.auth = provider.auth
65 self.logger = provider.logger
66
67 async def get_artists(self) -> AsyncGenerator[Artist]:
68 """Retrieve library artists."""
69 async for doc in self.api.paginate_jsonapi(
70 "userCollectionArtists/me/relationships/items", include=["items.profileArt"]
71 ):
72 for item in doc.data_list:
73 sync_item_id: str | None = None
74 try:
75 sync_item_id = _resource_id(item)
76 resource = _resolve_resource(doc, item)
77 except SKIPPABLE_ITEM_ERRORS as err:
78 self.provider.report_skipped_sync_item(MediaType.ARTIST, sync_item_id, err)
79 continue
80 if (
81 artist := _parse_or_skip(
82 parse_artist_v2,
83 self.provider,
84 doc,
85 resource,
86 MediaType.ARTIST,
87 sync_item_id,
88 )
89 ) is None:
90 continue
91 _set_date_added(artist, item)
92 yield artist
93
94 async def get_albums(self) -> AsyncGenerator[Album]:
95 """Retrieve library albums."""
96 async for doc in self.api.paginate_jsonapi(
97 "userCollectionAlbums/me/relationships/items",
98 include=["items.artists", "items.coverArt"],
99 replace_media="items",
100 ):
101 for item in doc.data_list:
102 sync_item_id = None
103 try:
104 sync_item_id = _resource_id(item)
105 resource = _resolve_resource(doc, item)
106 except SKIPPABLE_ITEM_ERRORS as err:
107 self.provider.report_skipped_sync_item(MediaType.ALBUM, sync_item_id, err)
108 continue
109 if (
110 album := _parse_or_skip(
111 parse_album_v2,
112 self.provider,
113 doc,
114 resource,
115 MediaType.ALBUM,
116 sync_item_id,
117 )
118 ) is None:
119 continue
120 _set_date_added(album, item)
121 yield album
122
123 async def get_tracks(self) -> AsyncGenerator[Track]:
124 """Retrieve library tracks."""
125 async for doc in self.api.paginate_jsonapi(
126 "userCollectionTracks/me/relationships/items",
127 include=["items.artists", "items.albums.coverArt"],
128 replace_media="items",
129 ):
130 for item in doc.data_list:
131 sync_item_id = None
132 try:
133 sync_item_id = _track_item_id(item)
134 resource = _resolve_resource(doc, item)
135 except SKIPPABLE_ITEM_ERRORS as err:
136 self.provider.report_skipped_sync_item(MediaType.TRACK, sync_item_id, err)
137 continue
138 if (
139 track := _parse_or_skip(
140 parse_track_v2,
141 self.provider,
142 doc,
143 resource,
144 MediaType.TRACK,
145 sync_item_id,
146 )
147 ) is None:
148 continue
149 _set_date_added(track, item)
150 try:
151 self.provider.note_replaced_track(item)
152 except SKIPPABLE_ITEM_ERRORS as err:
153 self.provider.report_skipped_sync_item(MediaType.TRACK, sync_item_id, err)
154 yield track
155
156 async def get_playlists(self) -> AsyncGenerator[Playlist]:
157 """Retrieve library playlists."""
158 # The official playlists collection returns both user playlists and
159 # favourited mixes (as MIX-type playlists).
160 async for doc in self.api.paginate_jsonapi(
161 "userCollectionPlaylists/me/relationships/items",
162 include=["items.coverArt", "items.owners"],
163 ):
164 for item in doc.data_list:
165 sync_item_id = None
166 try:
167 resource = _resolve_resource(doc, item)
168 sync_item_id = _playlist_item_id(resource)
169 except SKIPPABLE_ITEM_ERRORS as err:
170 self.provider.report_skipped_sync_item(MediaType.PLAYLIST, sync_item_id, err)
171 continue
172 if (
173 playlist := _parse_or_skip(
174 parse_playlist_v2,
175 self.provider,
176 doc,
177 resource,
178 MediaType.PLAYLIST,
179 sync_item_id,
180 )
181 ) is None:
182 continue
183 _set_date_added(playlist, item)
184 yield playlist
185
186 # The virtual "favorite tracks" playlist is a Music Assistant construct.
187 yield parse_favorite_tracks_playlist(self.provider)
188
189 async def add_item(self, item: MediaItemType) -> bool:
190 """Add item to library."""
191 return await self._modify_collection(item.item_id, item.media_type, "POST")
192
193 async def remove_item(self, prov_item_id: str, media_type: MediaType) -> bool:
194 """Remove item from library."""
195 return await self._modify_collection(prov_item_id, media_type, "DELETE")
196
197 async def _modify_collection(self, item_id: str, media_type: MediaType, method: str) -> bool:
198 """Add (POST) or remove (DELETE) an item via the official user collection."""
199 collection = _COLLECTIONS.get(media_type)
200 if not collection:
201 return False
202 resource_name, resource_type = collection
203 # Mixes are stored with a "mix_" prefix but live in the playlists collection.
204 if media_type == MediaType.PLAYLIST and item_id.startswith("mix_"):
205 item_id = item_id[4:]
206 try:
207 if method == "POST" and media_type == MediaType.TRACK:
208 return await self._add_track_with_healing(resource_name, resource_type, item_id)
209 if media_type == MediaType.TRACK:
210 # A track removal sent under a churned id is skipped server-side
211 # while looking successful; the cache-only redirect maps a
212 # known-stale id to the live one actually in the collection.
213 item_id = await self.provider.redirect_cached_id(item_id)
214 body = {"data": [{"type": resource_type, "id": item_id}]}
215 await self.api.write_jsonapi(method, f"{resource_name}/me/relationships/items", body)
216 return True
217 except _WRITE_ERRORS:
218 return False
219
220 async def _add_track_with_healing(
221 self, resource_name: str, resource_type: str, original_id: str
222 ) -> bool:
223 """Add a track to a user collection, healing a stale id if it was rejected."""
224 send_id = await self.provider.redirect_cached_id(original_id)
225 body = {"data": [{"type": resource_type, "id": send_id}]}
226 result = await self.api.write_jsonapi(
227 "POST", f"{resource_name}/me/relationships/items", body
228 )
229 # The add response reports rejected ids in meta.skipped. NOT_FOUND means the
230 # id is stale (Tidal churns tracks, re-adding them under new ids), so heal it
231 # via the live equivalent; ALREADY_PRESENT is a success. The top-level "data"
232 # is the paginated collection listing (new items append at the end), not an
233 # echo of what was accepted, so it must not be diffed to infer rejection.
234 skipped = (result.get("meta") or {}).get("skipped") or []
235 if not any(s.get("id") == send_id and s.get("reason") == "NOT_FOUND" for s in skipped):
236 return True
237 live = await self.provider.resolve_live_track_id(original_id)
238 if not live or live == send_id:
239 # The id is dead and could not be healed: nothing was added, so don't
240 # report success (MA would mark the track as in-library).
241 return False
242 retry_body = {"data": [{"type": resource_type, "id": live}]}
243 retry = await self.api.write_jsonapi(
244 "POST", f"{resource_name}/me/relationships/items", retry_body
245 )
246 retry_skipped = (retry.get("meta") or {}).get("skipped") or []
247 return not any(
248 s.get("id") == live and s.get("reason") == "NOT_FOUND" for s in retry_skipped
249 )
250
251
252def _set_date_added(media_item: MediaItemType, item: dict[str, Any]) -> None:
253 """Set date_added from a userCollection linkage item's addedAt meta."""
254 with suppress(AttributeError, TypeError, ValueError):
255 if added := (item.get("meta") or {}).get("addedAt"):
256 # the DB only persists whole-second precision, so truncate here to avoid
257 # every sync seeing a (sub-second) mismatch and flagging the item as changed
258 media_item.date_added = datetime.fromisoformat(added).replace(microsecond=0)
259
260
261def _playlist_item_id(resource: dict[str, Any]) -> str:
262 """Return the provider item ID used for a Tidal playlist resource."""
263 if not isinstance(item_id := resource["id"], str):
264 raise TypeError("Tidal playlist resource ID is not a string")
265 if resource.get("attributes", {}).get("playlistType") == "MIX":
266 return f"mix_{item_id}"
267 return item_id
268
269
270def _track_item_id(item: dict[str, Any]) -> str | None:
271 """Return the provider item ID that can be protected during track sync."""
272 replacement = (item.get("meta") or {}).get("replacement") or {}
273 if replacement.get("status") == "REPLACED":
274 return None
275 return _resource_id(item)
276
277
278def _resource_id(resource: dict[str, Any]) -> str | None:
279 """Return a JSON:API resource identifier as text."""
280 item_id = resource.get("id")
281 if item_id is not None and not isinstance(item_id, str):
282 raise TypeError("Tidal resource ID is not a string")
283 return item_id
284
285
286def _resolve_resource(doc: JsonApiDocument, item: dict[str, Any]) -> dict[str, Any]:
287 """Resolve a library linkage item to its included resource."""
288 if resource := doc.resolve(item):
289 return resource
290 raise ValueError(f"Tidal library item {_resource_id(item) or '[no id]'} has no resource")
291