/
/
/
1"""
2Builtin Library Recommendations Provider.
3
4Surfaces library-based discovery rows as recommendations on the Discover page.
5"""
6
7from __future__ import annotations
8
9from enum import StrEnum
10from typing import TYPE_CHECKING
11
12from music_assistant_models.enums import MediaType, ProviderFeature
13from music_assistant_models.media_items import RecommendationFolder, UniqueList
14
15from music_assistant.models.plugin import PluginProvider
16
17if TYPE_CHECKING:
18 from collections.abc import Sequence
19
20 from music_assistant_models.config_entries import (
21 ConfigEntry,
22 ConfigValueType,
23 ProviderConfig,
24 )
25 from music_assistant_models.media_items import BrowseFolder, ItemMapping, MediaItemType
26 from music_assistant_models.provider import ProviderManifest
27
28 from music_assistant.mass import MusicAssistant
29 from music_assistant.models import ProviderInstanceType
30
31SUPPORTED_FEATURES: set[ProviderFeature] = {
32 ProviderFeature.RECOMMENDATIONS,
33}
34
35
36class LibraryRowID(StrEnum):
37 """The item_ids of library recommendation rows."""
38
39 IN_PROGRESS = "in_progress"
40 RECENTLY_PLAYED = "recently_played"
41 RECENTLY_ADDED_TRACKS = "recently_added_tracks"
42 RECENTLY_ADDED_ALBUMS = "recently_added_albums"
43 RANDOM_ARTISTS = "random_artists"
44 RANDOM_ALBUMS = "random_albums"
45 RECENT_FAVORITE_TRACKS = "recent_favorite_tracks"
46 FAVORITE_PLAYLISTS = "favorite_playlists"
47 FAVORITE_RADIO = "favorite_radio"
48 RECENT_ARTISTS = "recent_artists"
49 RECENT_TRACKS = "recent_tracks"
50 FORGOTTEN_TRACKS = "forgotten_tracks"
51 FORGOTTEN_ALBUMS = "forgotten_albums"
52 FORGOTTEN_ARTISTS = "forgotten_artists"
53 MOST_PLAYED_TRACKS = "most_played_tracks"
54 NEVER_PLAYED_TRACKS = "never_played_tracks"
55
56
57async def setup(
58 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
59) -> ProviderInstanceType:
60 """Initialize provider(instance) with given configuration."""
61 return LibraryRecommendationsProvider(mass, manifest, config, SUPPORTED_FEATURES)
62
63
64async def get_config_entries(
65 mass: MusicAssistant, # noqa: ARG001
66 instance_id: str | None = None, # noqa: ARG001
67 action: str | None = None, # noqa: ARG001
68 values: dict[str, ConfigValueType] | None = None, # noqa: ARG001
69) -> tuple[ConfigEntry, ...]:
70 """Return config entries for this provider."""
71 return ()
72
73
74class LibraryRecommendationsProvider(PluginProvider):
75 """Builtin provider for library-based recommendation rows."""
76
77 async def get_recommendations(self) -> list[RecommendationFolder]:
78 """Get all library recommendation rows, without items."""
79 return [
80 _folder(
81 LibraryRowID.IN_PROGRESS, "In progress", "in_progress_items", "mdi-motion-play"
82 ),
83 _folder(
84 LibraryRowID.RECENTLY_PLAYED,
85 "Recently played",
86 "recently_played",
87 "mdi-motion-play",
88 ),
89 _folder(
90 LibraryRowID.RECENTLY_ADDED_TRACKS,
91 "Recently added tracks",
92 "recently_added_tracks",
93 "mdi-music-note-plus",
94 False,
95 ),
96 _folder(
97 LibraryRowID.RECENTLY_ADDED_ALBUMS,
98 "Recently added albums",
99 "recently_added_albums",
100 "mdi-album",
101 ),
102 _folder(
103 LibraryRowID.RANDOM_ARTISTS,
104 "Random artists",
105 "random_artists",
106 "mdi-account-music",
107 False,
108 ),
109 _folder(
110 LibraryRowID.RANDOM_ALBUMS, "Random albums", "random_albums", "mdi-album", False
111 ),
112 _folder(
113 LibraryRowID.RECENT_FAVORITE_TRACKS,
114 "Recently favorited tracks",
115 "recent_favorite_tracks",
116 "mdi-file-music",
117 False,
118 ),
119 _folder(
120 LibraryRowID.FAVORITE_PLAYLISTS,
121 "Favorite playlists",
122 "favorite_playlists",
123 "mdi-playlist-music",
124 False,
125 ),
126 _folder(
127 LibraryRowID.FAVORITE_RADIO,
128 "Favorite Radio stations",
129 "favorite_radio_stations",
130 "mdi-access-point",
131 False,
132 ),
133 _folder(
134 LibraryRowID.RECENT_ARTISTS,
135 "Recent artists",
136 "recent_artists",
137 "mdi-account-music",
138 False,
139 ),
140 _folder(
141 LibraryRowID.RECENT_TRACKS,
142 "Recent tracks",
143 "recent_tracks",
144 "mdi-music-note",
145 False,
146 ),
147 _folder(
148 LibraryRowID.FORGOTTEN_TRACKS,
149 "Forgotten Tracks",
150 "forgotten_tracks",
151 "mdi-timer-sand",
152 False,
153 ),
154 _folder(
155 LibraryRowID.FORGOTTEN_ALBUMS,
156 "Forgotten Albums",
157 "forgotten_albums",
158 "mdi-timer-sand",
159 False,
160 ),
161 _folder(
162 LibraryRowID.FORGOTTEN_ARTISTS,
163 "Forgotten Artists",
164 "forgotten_artists",
165 "mdi-timer-sand",
166 False,
167 ),
168 _folder(
169 LibraryRowID.MOST_PLAYED_TRACKS,
170 "Most Played Tracks",
171 "most_played_tracks",
172 "mdi-trophy",
173 False,
174 ),
175 _folder(
176 LibraryRowID.NEVER_PLAYED_TRACKS,
177 "Never / Rarely Played",
178 "never_played_tracks",
179 "mdi-sleep",
180 False,
181 ),
182 ]
183
184 async def get_recommendation_items(
185 self, item_id: str, providers: list[str] | None = None
186 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
187 """
188 Get the items for a single library recommendation row.
189
190 :param item_id: The item_id of the row, as returned by get_recommendations.
191 :param providers: Restrict items to those reachable through one of these provider
192 instance ids (OR semantics). An explicit empty list returns no items; None
193 applies no filter.
194 """
195 if providers is not None and not providers:
196 return UniqueList()
197 items: Sequence[MediaItemType | ItemMapping | BrowseFolder]
198 match item_id:
199 case LibraryRowID.IN_PROGRESS:
200 items = await self.mass.music.in_progress_items(limit=10, providers=providers)
201 case LibraryRowID.RECENTLY_PLAYED:
202 items = await self.mass.music.recently_played(
203 limit=10,
204 media_types=[
205 MediaType.ALBUM,
206 MediaType.TRACK,
207 MediaType.PLAYLIST,
208 MediaType.ARTIST,
209 MediaType.GENRE,
210 ],
211 user_initiated_only=True,
212 always_include_media_types=[MediaType.PODCAST, MediaType.AUDIOBOOK],
213 providers=providers,
214 )
215 case LibraryRowID.RECENTLY_ADDED_TRACKS:
216 items = await self.mass.music.tracks.library_items(
217 limit=10, order_by="timestamp_added_desc", reachable_via=providers
218 )
219 case LibraryRowID.RECENTLY_ADDED_ALBUMS:
220 items = await self.mass.music.albums.library_items(
221 limit=10, order_by="timestamp_added_desc", reachable_via=providers
222 )
223 case LibraryRowID.RANDOM_ARTISTS:
224 items = await self.mass.music.artists.library_items(
225 limit=10, order_by="random_play_count", reachable_via=providers
226 )
227 case LibraryRowID.RANDOM_ALBUMS:
228 items = await self.mass.music.albums.library_items(
229 limit=10, order_by="random_play_count", reachable_via=providers
230 )
231 case LibraryRowID.RECENT_FAVORITE_TRACKS:
232 items = await self.mass.music.tracks.library_items(
233 favorite=True,
234 limit=10,
235 order_by="timestamp_modified_desc",
236 reachable_via=providers,
237 )
238 case LibraryRowID.FAVORITE_PLAYLISTS:
239 items = await self.mass.music.playlists.library_items(
240 favorite=True, limit=10, order_by="random", reachable_via=providers
241 )
242 case LibraryRowID.FAVORITE_RADIO:
243 items = await self.mass.music.radio.library_items(
244 favorite=True, limit=10, order_by="play_count_desc", reachable_via=providers
245 )
246 case LibraryRowID.RECENT_ARTISTS:
247 items = await self.mass.music.recently_played(
248 limit=10,
249 media_types=[MediaType.ARTIST],
250 user_initiated_only=False,
251 providers=providers,
252 )
253 case LibraryRowID.RECENT_TRACKS:
254 items = await self.mass.music.recently_played(
255 limit=10,
256 media_types=[MediaType.TRACK],
257 user_initiated_only=False,
258 providers=providers,
259 )
260 case LibraryRowID.FORGOTTEN_TRACKS:
261 items = await self.mass.music.tracks.library_items(
262 limit=10, order_by="last_played", played_only=True, reachable_via=providers
263 )
264 case LibraryRowID.FORGOTTEN_ALBUMS:
265 items = await self.mass.music.albums.library_items(
266 limit=10, order_by="last_played", played_only=True, reachable_via=providers
267 )
268 case LibraryRowID.FORGOTTEN_ARTISTS:
269 items = await self.mass.music.artists.library_items(
270 limit=10, order_by="last_played", played_only=True, reachable_via=providers
271 )
272 case LibraryRowID.MOST_PLAYED_TRACKS:
273 items = await self.mass.music.tracks.library_items(
274 limit=10, order_by="play_count_desc", reachable_via=providers
275 )
276 case LibraryRowID.NEVER_PLAYED_TRACKS:
277 items = await self.mass.music.tracks.library_items(
278 limit=10, order_by="play_count", reachable_via=providers
279 )
280 case _:
281 items = []
282 return UniqueList(items)
283
284
285def _folder(
286 item_id: LibraryRowID,
287 name: str,
288 translation_key: str,
289 icon: str,
290 enabled_by_default: bool = True,
291) -> RecommendationFolder:
292 """Create a recommendation folder metadata object."""
293 return RecommendationFolder(
294 item_id=item_id.value,
295 provider="recommendations",
296 name=name,
297 translation_key=translation_key,
298 icon=icon,
299 enabled_by_default=enabled_by_default,
300 uri=f"library://folder/{item_id.value}",
301 supports_provider_filter=True,
302 )
303