/
/
/
1"""Emby Music Provider for MusicAssistant."""
2
3from __future__ import annotations
4
5import hashlib
6import socket
7from asyncio import TaskGroup
8from collections.abc import AsyncGenerator
9from typing import TYPE_CHECKING, Any
10from urllib.parse import urljoin
11
12from aiohttp import ClientResponseError
13from music_assistant_models.enums import (
14 MediaType,
15 ProviderFeature,
16 StreamType,
17)
18from music_assistant_models.errors import (
19 InvalidDataError,
20 LoginFailed,
21 MediaNotFoundError,
22 ProviderPermissionDenied,
23)
24from music_assistant_models.media_items import (
25 Album,
26 Artist,
27 MediaItemType,
28 Playlist,
29 SearchResults,
30 Track,
31)
32from music_assistant_models.streamdetails import StreamDetails
33
34from music_assistant.controllers.cache import use_cache
35from music_assistant.mass import MusicAssistant
36from music_assistant.models import ProviderInstanceType
37from music_assistant.models.music_provider import MusicProvider
38from music_assistant.providers.emby.const import (
39 ALBUM_FIELDS,
40 ARTIST_FIELDS,
41 AUTH_ACCESS_TOKEN,
42 AUTH_USER,
43 ITEM_KEY_COLLECTION_TYPE,
44 ITEM_KEY_ID,
45 ITEM_KEY_MEDIA_STREAMS,
46 ITEM_KEY_RUNTIME_TICKS,
47 ITEM_LIMIT,
48 ITEMS,
49 TRACK_FIELDS,
50)
51from music_assistant.providers.emby.parsers import (
52 parse_album,
53 parse_artist,
54 parse_playlist,
55 parse_stream_details,
56 parse_track,
57)
58
59if TYPE_CHECKING:
60 from music_assistant_models.config_entries import (
61 ConfigEntry,
62 ProviderConfig,
63 )
64 from music_assistant_models.provider import ProviderManifest
65
66from music_assistant.constants import (
67 APPLICATION_NAME,
68 CONF_IP_ADDRESS,
69 CONF_PASSWORD,
70 CONF_USERNAME,
71)
72
73SUPPORTED_FEATURES = {
74 ProviderFeature.LIBRARY_ARTISTS,
75 ProviderFeature.LIBRARY_ALBUMS,
76 ProviderFeature.LIBRARY_TRACKS,
77 ProviderFeature.LIBRARY_PLAYLISTS,
78 ProviderFeature.BROWSE,
79 ProviderFeature.SEARCH,
80 ProviderFeature.ARTIST_ALBUMS,
81 ProviderFeature.ARTIST_TOPTRACKS,
82 ProviderFeature.SIMILAR_TRACKS,
83}
84
85
86async def setup(
87 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
88) -> ProviderInstanceType:
89 """Initialize provider(instance) with given configuration."""
90 return EmbyProvider(mass, manifest, config, SUPPORTED_FEATURES)
91
92
93class EmbyProvider(MusicProvider):
94 """Provider for an Emby music library (uses Emby REST API)."""
95
96 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
97 """Get configuration entries for provider setup."""
98 return ()
99
100 async def handle_async_init(self) -> None:
101 """Initialize provider(instance) with given configuration."""
102 username = str(self.get_setup_value(CONF_USERNAME))
103 password = str(self.get_setup_value(CONF_PASSWORD) or "")
104 self._base_url = str(self.get_setup_value(CONF_IP_ADDRESS)).rstrip("/") + "/"
105 self._session = self.mass.http_session
106
107 # stable device id
108 device_id = hashlib.sha256(f"{self.mass.server_id}+{username}".encode()).hexdigest()
109 self._device_id = device_id
110 self._device_name = socket.gethostname()
111
112 # authenticate against Emby /Users/AuthenticateByName
113 auth_url = urljoin(self._base_url, "Users/AuthenticateByName")
114 payload = {"Username": username, "Pw": password}
115 headers = {
116 "Accept": "application/json",
117 "X-Emby-Authorization": (
118 f'MediaBrowser Client="{APPLICATION_NAME}", '
119 f'Device="{self._device_name}", '
120 f'DeviceId="{device_id}", '
121 f'Version="{self.mass.version}"'
122 ),
123 }
124 try:
125 async with self._session.post(auth_url, json=payload, headers=headers) as resp:
126 resp.raise_for_status()
127 data = await resp.json()
128 except ClientResponseError as err:
129 if err.status == 401:
130 raise LoginFailed("Unauthorized: invalid credentials") from err
131 if err.status == 403:
132 raise ProviderPermissionDenied("Forbidden: insufficient permissions") from err
133 if err.status == 404:
134 raise MediaNotFoundError("Authentication endpoint not found") from err
135 raise
136
137 # store token and user id
138 token = data.get(AUTH_ACCESS_TOKEN)
139 user = data.get(AUTH_USER)
140 if not token or not user:
141 raise LoginFailed("Authentication failed: missing token/user in response")
142 self._token = token
143 self._user_id = user.get(ITEM_KEY_ID)
144 self._headers = {
145 "Accept": "application/json",
146 "X-Emby-Token": self._token,
147 "X-Emby-Authorization": (
148 f'MediaBrowser Client="{APPLICATION_NAME}", '
149 f'Device="{self._device_name}", '
150 f'DeviceId="{device_id}", '
151 f'Version="{self.mass.version}", '
152 f'Token="{self._token}"'
153 ),
154 }
155
156 @property
157 def is_streaming_provider(self) -> bool:
158 """Return True if provider supports streaming."""
159 return False
160
161 async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
162 url = urljoin(self._base_url, path.lstrip("/"))
163 try:
164 async with self._session.get(url, headers=self._headers, params=params) as resp:
165 resp.raise_for_status()
166 return await resp.json() # type: ignore[no-any-return]
167 except ClientResponseError as err:
168 if err.status == 401:
169 raise LoginFailed("Unauthorized: invalid credentials") from err
170 if err.status == 403:
171 raise ProviderPermissionDenied("Forbidden: insufficient permissions") from err
172 if err.status == 404:
173 raise MediaNotFoundError(f"Item {path} not found") from err
174 raise
175
176 async def _post(self, path: str, json: dict[str, Any] | None = None) -> None:
177 url = urljoin(self._base_url, path.lstrip("/"))
178 try:
179 async with self._session.post(url, headers=self._headers, json=json) as resp:
180 resp.raise_for_status()
181 except ClientResponseError as err:
182 if err.status == 401:
183 raise LoginFailed("Unauthorized: invalid credentials") from err
184 if err.status == 403:
185 raise ProviderPermissionDenied("Forbidden: insufficient permissions") from err
186 if err.status == 404:
187 raise MediaNotFoundError(f"Item {path} not found") from err
188 raise
189
190 async def _search_items(
191 self, search_query: str, include_types: str, fields: list[str], limit: int
192 ) -> list[dict[str, Any]]:
193 params = {
194 "SearchTerm": search_query,
195 "IncludeItemTypes": include_types,
196 "EnableUserData": "true",
197 "Fields": ",".join(fields or []),
198 "Limit": str(limit),
199 "Recursive": "true",
200 }
201 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
202 return resp.get(ITEMS, []) # type: ignore[no-any-return]
203
204 async def _search_track(self, search_query: str, limit: int) -> list[Track]:
205 items = await self._search_items(search_query, "Audio", TRACK_FIELDS, limit)
206 return [parse_track(self.instance_id, self, item) for item in items]
207
208 async def _search_album(self, search_query: str, limit: int) -> list[Album]:
209 albumname = search_query.split(" - ", 1)[1] if " - " in search_query else search_query
210 items = await self._search_items(albumname, "MusicAlbum", ALBUM_FIELDS, limit)
211 return [parse_album(self.instance_id, self, item) for item in items]
212
213 async def _search_artist(self, search_query: str, limit: int) -> list[Artist]:
214 items = await self._search_items(search_query, "MusicArtist", ARTIST_FIELDS, limit)
215 return [parse_artist(self.instance_id, self, item) for item in items]
216
217 async def _search_playlist(self, search_query: str, limit: int) -> list[Playlist]:
218 items = await self._search_items(search_query, "Playlist", [], limit)
219 return [parse_playlist(self.instance_id, self, item) for item in items]
220
221 @use_cache(60 * 15)
222 async def search(
223 self,
224 search_query: str,
225 media_types: list[MediaType],
226 limit: int = 20,
227 ) -> SearchResults:
228 """Search for media items in the Emby library."""
229 artists = None
230 albums = None
231 tracks = None
232 playlists = None
233
234 async with TaskGroup() as tg:
235 if MediaType.ARTIST in media_types:
236 artists = tg.create_task(self._search_artist(search_query, limit))
237 if MediaType.ALBUM in media_types:
238 albums = tg.create_task(self._search_album(search_query, limit))
239 if MediaType.TRACK in media_types:
240 tracks = tg.create_task(self._search_track(search_query, limit))
241 if MediaType.PLAYLIST in media_types:
242 playlists = tg.create_task(self._search_playlist(search_query, limit))
243
244 search_results = SearchResults()
245 if artists:
246 search_results.artists = artists.result()
247 if albums:
248 search_results.albums = albums.result()
249 if tracks:
250 search_results.tracks = tracks.result()
251 if playlists:
252 search_results.playlists = playlists.result()
253 return search_results
254
255 async def get_library_artists(self) -> AsyncGenerator[Artist]:
256 """Yield all artists from the music library."""
257 libs = await self._get_music_libraries()
258 for lib in libs:
259 params = {
260 "ParentId": lib[ITEM_KEY_ID],
261 "IncludeItemTypes": "MusicArtist",
262 "EnableUserData": "true",
263 "Fields": ",".join(ARTIST_FIELDS),
264 "Recursive": "true",
265 }
266 page = 0
267 while True:
268 params["StartIndex"] = str(page * ITEM_LIMIT)
269 params["Limit"] = ITEM_LIMIT
270 resp = await self._get("Artists", params=params)
271 items = self._get_response_items(resp, "artist")
272 if not items:
273 break
274 for artist in items:
275 if not isinstance(artist, dict):
276 self.report_skipped_sync_item(
277 MediaType.ARTIST,
278 None,
279 InvalidDataError("Emby artist listing contains a non-object entry"),
280 )
281 continue
282 item_id = artist.get(ITEM_KEY_ID)
283 if not isinstance(item_id, str) or not item_id.strip():
284 self.report_skipped_sync_item(
285 MediaType.ARTIST,
286 None,
287 InvalidDataError("Emby artist listing entry has no id"),
288 )
289 continue
290 yield parse_artist(self.instance_id, self, artist)
291 page += 1
292
293 async def get_library_albums(self) -> AsyncGenerator[Album]:
294 """Yield all albums from the music library."""
295 libs = await self._get_music_libraries()
296 for lib in libs:
297 params = {
298 "ParentId": lib[ITEM_KEY_ID],
299 "IncludeItemTypes": "MusicAlbum",
300 "EnableUserData": "true",
301 "Fields": ",".join(ALBUM_FIELDS),
302 "Recursive": "true",
303 }
304 page = 0
305 while True:
306 params["StartIndex"] = str(page * ITEM_LIMIT)
307 params["Limit"] = ITEM_LIMIT
308 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
309 items = self._get_response_items(resp, "album")
310 if not items:
311 break
312 for album in items:
313 if not isinstance(album, dict):
314 self.report_skipped_sync_item(
315 MediaType.ALBUM,
316 None,
317 InvalidDataError("Emby album listing contains a non-object entry"),
318 )
319 continue
320 item_id = album.get(ITEM_KEY_ID)
321 if not isinstance(item_id, str) or not item_id.strip():
322 self.report_skipped_sync_item(
323 MediaType.ALBUM,
324 None,
325 InvalidDataError("Emby album listing entry has no id"),
326 )
327 continue
328 yield parse_album(self.instance_id, self, album)
329 page += 1
330
331 async def get_library_tracks(self) -> AsyncGenerator[Track]:
332 """Yield all tracks from the music library."""
333 libs = await self._get_music_libraries()
334 for lib in libs:
335 params = {
336 "ParentId": lib[ITEM_KEY_ID],
337 "IncludeItemTypes": "Audio",
338 "EnableUserData": "true",
339 "Fields": ",".join(TRACK_FIELDS),
340 "Recursive": "true",
341 }
342 page = 0
343 while True:
344 params["StartIndex"] = str(page * ITEM_LIMIT)
345 params["Limit"] = ITEM_LIMIT
346 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
347 items = self._get_response_items(resp, "track")
348 if not items:
349 break
350 for track in items:
351 if not isinstance(track, dict):
352 self.report_skipped_sync_item(
353 MediaType.TRACK,
354 None,
355 InvalidDataError("Emby track listing contains a non-object entry"),
356 )
357 continue
358 item_id = track.get(ITEM_KEY_ID)
359 if not isinstance(item_id, str) or not item_id.strip():
360 self.report_skipped_sync_item(
361 MediaType.TRACK,
362 None,
363 InvalidDataError("Emby track listing entry has no id"),
364 )
365 continue
366 media_streams = track.get(ITEM_KEY_MEDIA_STREAMS)
367 if not isinstance(media_streams, list) or not media_streams:
368 self.report_skipped_sync_item(
369 MediaType.TRACK,
370 item_id,
371 InvalidDataError("Emby track has no media streams"),
372 )
373 continue
374 yield parse_track(self.instance_id, self, track)
375 page += 1
376
377 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
378 """Yield all playlists from the music library."""
379 libs = await self._get_music_libraries()
380 for lib in libs:
381 params = {
382 "ParentId": lib[ITEM_KEY_ID],
383 "IncludeItemTypes": "Playlist",
384 "EnableUserData": "true",
385 "Recursive": "true",
386 }
387 page = 0
388 while True:
389 params["StartIndex"] = str(page * ITEM_LIMIT)
390 params["Limit"] = ITEM_LIMIT
391 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
392 items = self._get_response_items(resp, "playlist")
393 if not items:
394 break
395 for playlist in items:
396 if not isinstance(playlist, dict):
397 self.report_skipped_sync_item(
398 MediaType.PLAYLIST,
399 None,
400 InvalidDataError("Emby playlist listing contains a non-object entry"),
401 )
402 continue
403 item_id = playlist.get(ITEM_KEY_ID)
404 if not isinstance(item_id, str) or not item_id.strip():
405 self.report_skipped_sync_item(
406 MediaType.PLAYLIST,
407 None,
408 InvalidDataError("Emby playlist listing entry has no id"),
409 )
410 continue
411 yield parse_playlist(self.instance_id, self, playlist)
412 page += 1
413
414 @use_cache(3600)
415 async def get_album(self, prov_album_id: str) -> Album:
416 """Get album by provider album id."""
417 album = await self._get(
418 f"Users/{self._user_id}/Items/{prov_album_id}",
419 params={
420 "EnableUserData": "true",
421 "Fields": ",".join(ALBUM_FIELDS),
422 "Recursive": "true",
423 },
424 )
425 return parse_album(self.instance_id, self, album)
426
427 @use_cache(3600)
428 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
429 """Get tracks for a given album by provider album id."""
430 params = {
431 "ParentId": prov_album_id,
432 "IncludeItemTypes": "Audio",
433 "EnableUserData": "true",
434 "Fields": ",".join(TRACK_FIELDS),
435 "Limit": ITEM_LIMIT,
436 "Recursive": "true",
437 }
438 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
439 return [parse_track(self.instance_id, self, item) for item in resp.get(ITEMS, [])]
440
441 @use_cache(60 * 15)
442 async def get_artist(self, prov_artist_id: str) -> Artist:
443 """Get artist by provider artist id."""
444 artist_data = await self._get(
445 f"Users/{self._user_id}/Items/{prov_artist_id}",
446 params={"EnableUserData": "true", "Fields": ",".join(ARTIST_FIELDS)},
447 )
448
449 return parse_artist(self.instance_id, self, artist_data)
450
451 @use_cache(3600)
452 async def get_artist_toptracks(self, prov_artist_id: str, limit: int = 25) -> list[Track]:
453 """Get top tracks for a given artist by provider artist id."""
454 params = {
455 "ArtistIds": prov_artist_id,
456 "IncludeItemTypes": "Audio",
457 "EnableUserData": "true",
458 "Fields": ",".join(TRACK_FIELDS),
459 "Recursive": "true",
460 "Limit": str(limit),
461 "SortBy": "PlayCount",
462 "SortOrder": "Descending",
463 }
464 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
465 return [parse_track(self.instance_id, self, item) for item in resp.get(ITEMS, [])]
466
467 @use_cache(60 * 15)
468 async def get_track(self, prov_track_id: str) -> Track:
469 """Get track by provider track id."""
470 track = await self._get(
471 f"Users/{self._user_id}/Items/{prov_track_id}",
472 params={"EnableUserData": "true", "Fields": ",".join(TRACK_FIELDS)},
473 )
474
475 return parse_track(self.instance_id, self, track)
476
477 @use_cache(60 * 15)
478 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
479 """Get playlist by provider playlist id."""
480 playlist = await self._get(
481 f"Users/{self._user_id}/Items/{prov_playlist_id}",
482 params={"EnableUserData": "true"},
483 )
484
485 return parse_playlist(self.instance_id, self, playlist)
486
487 @use_cache(3600)
488 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
489 """Get tracks for a given playlist by provider playlist id."""
490 result: list[Track] = []
491 params = {
492 "ParentId": prov_playlist_id,
493 "IncludeItemTypes": "Audio",
494 "EnableUserData": "true",
495 "Fields": ",".join(TRACK_FIELDS),
496 "Limit": ITEM_LIMIT,
497 "StartIndex": str(page * ITEM_LIMIT),
498 }
499 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
500 for index, item in enumerate(resp.get(ITEMS, []), 1):
501 pos = (page * ITEM_LIMIT) + index
502 if track := parse_track(self.instance_id, self, item):
503 track.position = pos
504 result.append(track)
505
506 return result
507
508 @use_cache(3600)
509 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
510 """Get albums for a given artist by provider artist id."""
511 params = {
512 "AlbumArtistIds": prov_artist_id,
513 "IncludeItemTypes": "MusicAlbum",
514 "Fields": ",".join(ALBUM_FIELDS),
515 "EnableUserData": "true",
516 "Recursive": "true",
517 }
518 resp = await self._get(f"Users/{self._user_id}/Items", params=params)
519 return [parse_album(self.instance_id, self, album) for album in resp.get(ITEMS, [])]
520
521 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
522 """Get stream details for given item id and media type."""
523 track_data = await self._get(
524 f"Users/{self._user_id}/Items/{item_id}",
525 params={"EnableUserData": "true", "Fields": ",".join(TRACK_FIELDS)},
526 )
527
528 audio_format = parse_stream_details(track_data)
529
530 url = urljoin(self._base_url, f"Audio/{item_id}/universal")
531 params = {
532 "Container": audio_format.content_type,
533 "AudioCodec": audio_format.codec_type,
534 "AudioSampleRate": audio_format.sample_rate,
535 "AudioChannels": audio_format.channels,
536 "Static": "true",
537 "api_key": self._token,
538 }
539 query = "&".join([f"{k}={v}" for k, v in params.items()])
540
541 duration = int(
542 track_data.get(ITEM_KEY_RUNTIME_TICKS, 0) / 10000000
543 ) # Convert ticks to seconds
544
545 return StreamDetails(
546 item_id=item_id,
547 provider=self.instance_id,
548 audio_format=audio_format,
549 stream_type=StreamType.HTTP,
550 duration=duration,
551 path=f"{url}?{query}",
552 can_seek=True,
553 allow_seek=True,
554 )
555
556 @use_cache(3600)
557 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
558 """Get similar tracks."""
559 resp = await self._get(
560 f"Items/{prov_track_id}/Similar",
561 params={"Limit": str(limit), "Fields": ",".join(TRACK_FIELDS)},
562 )
563
564 return [parse_track(self.instance_id, self, t) for t in resp.get(ITEMS, [])]
565
566 async def _get_music_libraries(self) -> list[dict[str, Any]]:
567 resp = await self._get("Library/MediaFolders")
568 libs = self._get_response_items(resp, "music library")
569 result = []
570 for library in libs:
571 if not isinstance(library, dict):
572 raise InvalidDataError("Emby music-library response contains a non-object entry")
573 if ITEM_KEY_COLLECTION_TYPE in library:
574 collection_type = library.get(ITEM_KEY_COLLECTION_TYPE, "").lower()
575 if collection_type == "music":
576 result.append(library)
577 return result
578
579 async def on_played(
580 self,
581 media_type: MediaType,
582 prov_item_id: str,
583 fully_played: bool,
584 position: int,
585 media_item: MediaItemType,
586 is_playing: bool = False,
587 ) -> None:
588 """Handle media item played event."""
589 if media_type != MediaType.TRACK:
590 return
591 if fully_played:
592 await self._post(f"Users/{self._user_id}/PlayedItems/{prov_item_id}")
593 if is_playing:
594 await self._post(
595 f"/Users/{self._user_id}/Items/{prov_item_id}/UserData",
596 json={"PlaybackPositionTicks": position * 10000000},
597 )
598 if not fully_played and position == 0:
599 await self._post(f"/Users/{self._user_id}/PlayedItems/{prov_item_id}/Delete")
600
601 @staticmethod
602 def _get_response_items(response: dict[str, Any], item_type: str) -> list[Any]:
603 """Return the required item list from an Emby listing response."""
604 items = response.get(ITEMS)
605 if not isinstance(items, list):
606 raise InvalidDataError(f"Emby {item_type} response contains no item list")
607 return items
608