/
/
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 .parsers import parse_favorite_tracks_playlist
14from .parsers_v2 import parse_album as parse_album_v2
15from .parsers_v2 import parse_artist as parse_artist_v2
16from .parsers_v2 import parse_playlist as parse_playlist_v2
17from .parsers_v2 import parse_track as parse_track_v2
18
19# MediaType -> (official collection resource, JSON:API resource type).
20_COLLECTIONS = {
21 MediaType.ARTIST: ("userCollectionArtists", "artists"),
22 MediaType.ALBUM: ("userCollectionAlbums", "albums"),
23 MediaType.TRACK: ("userCollectionTracks", "tracks"),
24 MediaType.PLAYLIST: ("userCollectionPlaylists", "playlists"),
25}
26
27# Errors treated as a failed (best-effort) collection write.
28_WRITE_ERRORS = (ClientError, MediaNotFoundError, ResourceTemporarilyUnavailable)
29
30if TYPE_CHECKING:
31 from collections.abc import AsyncGenerator
32
33 from music_assistant_models.media_items import (
34 Album,
35 Artist,
36 MediaItemType,
37 Playlist,
38 Track,
39 )
40
41 from .provider import TidalProvider
42
43
44class TidalLibraryManager:
45 """Manages Tidal library operations."""
46
47 def __init__(self, provider: TidalProvider):
48 """Initialize library manager."""
49 self.provider = provider
50 self.api = provider.api
51 self.auth = provider.auth
52 self.logger = provider.logger
53
54 async def get_artists(self) -> AsyncGenerator[Artist]:
55 """Retrieve library artists."""
56 async for doc in self.api.paginate_jsonapi(
57 "userCollectionArtists/me/relationships/items", include=["items.profileArt"]
58 ):
59 for item in doc.data_list:
60 if resource := doc.resolve(item):
61 artist = parse_artist_v2(self.provider, doc, resource)
62 _set_date_added(artist, item)
63 yield artist
64
65 async def get_albums(self) -> AsyncGenerator[Album]:
66 """Retrieve library albums."""
67 async for doc in self.api.paginate_jsonapi(
68 "userCollectionAlbums/me/relationships/items",
69 include=["items.artists", "items.coverArt"],
70 replace_media="items",
71 ):
72 for item in doc.data_list:
73 if resource := doc.resolve(item):
74 album = parse_album_v2(self.provider, doc, resource)
75 _set_date_added(album, item)
76 yield album
77
78 async def get_tracks(self) -> AsyncGenerator[Track]:
79 """Retrieve library tracks."""
80 async for doc in self.api.paginate_jsonapi(
81 "userCollectionTracks/me/relationships/items",
82 include=["items.artists", "items.albums.coverArt"],
83 replace_media="items",
84 ):
85 for item in doc.data_list:
86 if resource := doc.resolve(item):
87 track = parse_track_v2(self.provider, doc, resource)
88 _set_date_added(track, item)
89 self.provider.note_replaced_track(item)
90 yield track
91
92 async def get_playlists(self) -> AsyncGenerator[Playlist]:
93 """Retrieve library playlists."""
94 # The official playlists collection returns both user playlists and
95 # favourited mixes (as MIX-type playlists).
96 async for doc in self.api.paginate_jsonapi(
97 "userCollectionPlaylists/me/relationships/items",
98 include=["items.coverArt", "items.owners"],
99 ):
100 for item in doc.data_list:
101 if resource := doc.resolve(item):
102 playlist = parse_playlist_v2(self.provider, doc, resource)
103 _set_date_added(playlist, item)
104 yield playlist
105
106 # The virtual "favorite tracks" playlist is a Music Assistant construct.
107 yield parse_favorite_tracks_playlist(self.provider)
108
109 async def add_item(self, item: MediaItemType) -> bool:
110 """Add item to library."""
111 return await self._modify_collection(item.item_id, item.media_type, "POST")
112
113 async def remove_item(self, prov_item_id: str, media_type: MediaType) -> bool:
114 """Remove item from library."""
115 return await self._modify_collection(prov_item_id, media_type, "DELETE")
116
117 async def _modify_collection(self, item_id: str, media_type: MediaType, method: str) -> bool:
118 """Add (POST) or remove (DELETE) an item via the official user collection."""
119 collection = _COLLECTIONS.get(media_type)
120 if not collection:
121 return False
122 resource_name, resource_type = collection
123 # Mixes are stored with a "mix_" prefix but live in the playlists collection.
124 if media_type == MediaType.PLAYLIST and item_id.startswith("mix_"):
125 item_id = item_id[4:]
126 try:
127 if method == "POST" and media_type == MediaType.TRACK:
128 return await self._add_track_with_healing(resource_name, resource_type, item_id)
129 if media_type == MediaType.TRACK:
130 # A track removal sent under a churned id is skipped server-side
131 # while looking successful; the cache-only redirect maps a
132 # known-stale id to the live one actually in the collection.
133 item_id = await self.provider.redirect_cached_id(item_id)
134 body = {"data": [{"type": resource_type, "id": item_id}]}
135 await self.api.write_jsonapi(method, f"{resource_name}/me/relationships/items", body)
136 return True
137 except _WRITE_ERRORS:
138 return False
139
140 async def _add_track_with_healing(
141 self, resource_name: str, resource_type: str, original_id: str
142 ) -> bool:
143 """Add a track to a user collection, healing a stale id if it was rejected."""
144 send_id = await self.provider.redirect_cached_id(original_id)
145 body = {"data": [{"type": resource_type, "id": send_id}]}
146 result = await self.api.write_jsonapi(
147 "POST", f"{resource_name}/me/relationships/items", body
148 )
149 # The add response reports rejected ids in meta.skipped. NOT_FOUND means the
150 # id is stale (Tidal churns tracks, re-adding them under new ids), so heal it
151 # via the live equivalent; ALREADY_PRESENT is a success. The top-level "data"
152 # is the paginated collection listing (new items append at the end), not an
153 # echo of what was accepted, so it must not be diffed to infer rejection.
154 skipped = (result.get("meta") or {}).get("skipped") or []
155 if not any(s.get("id") == send_id and s.get("reason") == "NOT_FOUND" for s in skipped):
156 return True
157 live = await self.provider.resolve_live_track_id(original_id)
158 if not live or live == send_id:
159 # The id is dead and could not be healed: nothing was added, so don't
160 # report success (MA would mark the track as in-library).
161 return False
162 retry_body = {"data": [{"type": resource_type, "id": live}]}
163 retry = await self.api.write_jsonapi(
164 "POST", f"{resource_name}/me/relationships/items", retry_body
165 )
166 retry_skipped = (retry.get("meta") or {}).get("skipped") or []
167 return not any(
168 s.get("id") == live and s.get("reason") == "NOT_FOUND" for s in retry_skipped
169 )
170
171
172def _set_date_added(media_item: MediaItemType, item: dict[str, Any]) -> None:
173 """Set date_added from a userCollection linkage item's addedAt meta."""
174 if added := (item.get("meta") or {}).get("addedAt"):
175 with suppress(ValueError):
176 # the DB only persists whole-second precision, so truncate here to avoid
177 # every sync seeing a (sub-second) mismatch and flagging the item as changed
178 media_item.date_added = datetime.fromisoformat(added).replace(microsecond=0)
179