/
/
/
1"""Jellyfin support 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
10
11from aiojellyfin import MediaLibrary as JellyMediaLibrary
12from aiojellyfin import NotFound, authenticate_by_name
13from aiojellyfin.session import SessionConfiguration
14from music_assistant_models.enums import MediaType, ProviderFeature, StreamType
15from music_assistant_models.errors import LoginFailed, MediaNotFoundError
16from music_assistant_models.media_items import (
17 Album,
18 Artist,
19 Playlist,
20 ProviderMapping,
21 SearchResults,
22 Track,
23)
24from music_assistant_models.streamdetails import StreamDetails
25
26from music_assistant.constants import UNKNOWN_ARTIST, UNKNOWN_ARTIST_ID_MBID
27from music_assistant.controllers.cache import use_cache
28from music_assistant.mass import MusicAssistant
29from music_assistant.models import ProviderInstanceType
30from music_assistant.models.music_provider import MusicProvider
31from music_assistant.providers.jellyfin.parsers import (
32 audio_format,
33 parse_album,
34 parse_artist,
35 parse_playlist,
36 parse_track,
37)
38
39from .const import (
40 ALBUM_FIELDS,
41 ARTIST_FIELDS,
42 COLLECTION_TYPE_MUSIC,
43 COLLECTION_TYPE_PLAYLISTS,
44 ITEM_KEY_COLLECTION_TYPE,
45 ITEM_KEY_ID,
46 ITEM_KEY_MEDIA_STREAMS,
47 ITEM_KEY_MEDIA_TYPE,
48 ITEM_KEY_NAME,
49 ITEM_KEY_RUNTIME_TICKS,
50 MEDIA_TYPE_AUDIO,
51 SUPPORTED_CONTAINER_FORMATS,
52 TRACK_FIELDS,
53 USER_APP_NAME,
54)
55
56if TYPE_CHECKING:
57 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
58 from music_assistant_models.provider import ProviderManifest
59
60CONF_URL = "url"
61CONF_USERNAME = "username"
62CONF_PASSWORD = "password"
63CONF_VERIFY_SSL = "verify_ssl"
64SUPPORTED_FEATURES = {
65 ProviderFeature.LIBRARY_ARTISTS,
66 ProviderFeature.LIBRARY_ALBUMS,
67 ProviderFeature.LIBRARY_TRACKS,
68 ProviderFeature.LIBRARY_PLAYLISTS,
69 ProviderFeature.BROWSE,
70 ProviderFeature.SEARCH,
71 ProviderFeature.ARTIST_ALBUMS,
72 ProviderFeature.SIMILAR_TRACKS,
73}
74
75
76async def setup(
77 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
78) -> ProviderInstanceType:
79 """Initialize provider(instance) with given configuration."""
80 return JellyfinProvider(mass, manifest, config, SUPPORTED_FEATURES)
81
82
83class JellyfinProvider(MusicProvider):
84 """Provider for a jellyfin music library."""
85
86 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
87 """Return Config entries to setup this provider."""
88 return ()
89
90 async def handle_async_init(self) -> None:
91 """Initialize provider(instance) with given configuration."""
92 username = str(self.get_setup_value(CONF_USERNAME))
93
94 # Device ID should be stable between reboots
95 # Otherwise every time the provider starts we "leak" a new device
96 # entry in the Jellyfin backend, which creates devices and entities
97 # in HA if they also use the Jellyfin integration there.
98
99 # We follow a suggestion a Jellyfin dev gave to HA and use an ID
100 # that is stable even if provider is removed and re-added.
101 # They said mix in username in case the same device/app has 2
102 # connections to the same servers
103
104 # Neither of these are secrets (username is handed over to mint a
105 # token and server_id is used in zeroconf) but hash them anyway as its meant
106 # to be an opaque identifier
107
108 device_id = hashlib.sha256(f"{self.mass.server_id}+{username}".encode()).hexdigest()
109 verify_ssl = bool(self.get_setup_value(CONF_VERIFY_SSL))
110 http_session = self.mass.http_session if verify_ssl else self.mass.http_session_no_ssl
111
112 session_config = SessionConfiguration(
113 session=http_session,
114 url=str(self.get_setup_value(CONF_URL)),
115 verify_ssl=verify_ssl,
116 app_name=USER_APP_NAME,
117 app_version=self.mass.version,
118 device_name=socket.gethostname(),
119 device_id=device_id,
120 )
121
122 try:
123 self._client = await authenticate_by_name(
124 session_config,
125 username,
126 str(self.get_setup_value(CONF_PASSWORD) or ""),
127 )
128 except Exception as err:
129 raise LoginFailed(f"Authentication failed: {err}") from err
130
131 @property
132 def is_streaming_provider(self) -> bool:
133 """Return True if the provider is a streaming provider."""
134 return False
135
136 async def _search_track(self, search_query: str, limit: int) -> list[Track]:
137 resultset = (
138 await self._client.tracks.search_term(search_query)
139 .limit(limit)
140 .enable_userdata()
141 .fields(*TRACK_FIELDS)
142 .request()
143 )
144 tracks = []
145 for item in resultset["Items"]:
146 tracks.append(parse_track(self.logger, self.instance_id, self._client, item))
147 return tracks
148
149 async def _search_album(self, search_query: str, limit: int) -> list[Album]:
150 # an "Artist - Album" style query: search on the album part only
151 albumname = search_query.split(" - ", 1)[1] if " - " in search_query else search_query
152 resultset = (
153 await self._client.albums.search_term(albumname)
154 .limit(limit)
155 .enable_userdata()
156 .fields(*ALBUM_FIELDS)
157 .request()
158 )
159 albums = []
160 for item in resultset["Items"]:
161 albums.append(parse_album(self.logger, self.instance_id, self._client, item))
162 return albums
163
164 async def _search_artist(self, search_query: str, limit: int) -> list[Artist]:
165 resultset = (
166 await self._client.artists.search_term(search_query)
167 .limit(limit)
168 .enable_userdata()
169 .fields(*ARTIST_FIELDS)
170 .request()
171 )
172 artists = []
173 for item in resultset["Items"]:
174 artists.append(parse_artist(self.logger, self.instance_id, self._client, item))
175 return artists
176
177 async def _search_playlist(self, search_query: str, limit: int) -> list[Playlist]:
178 resultset = (
179 await self._client.playlists.search_term(search_query)
180 .limit(limit)
181 .enable_userdata()
182 .request()
183 )
184 playlists = []
185 for item in resultset["Items"]:
186 playlists.append(parse_playlist(self.instance_id, self._client, item))
187 return playlists
188
189 @use_cache(60 * 15) # Cache for 15 minutes
190 async def search(
191 self,
192 search_query: str,
193 media_types: list[MediaType],
194 limit: int = 20,
195 ) -> SearchResults:
196 """
197 Perform search on the Jellyfin library.
198
199 :param search_query: Search query.
200 :param media_types: A list of media_types to include. All types if None.
201 :param limit: Number of items to return in the search (per type).
202 """
203 artists = None
204 albums = None
205 tracks = None
206 playlists = None
207
208 async with TaskGroup() as tg:
209 if MediaType.ARTIST in media_types:
210 artists = tg.create_task(self._search_artist(search_query, limit))
211 if MediaType.ALBUM in media_types:
212 albums = tg.create_task(self._search_album(search_query, limit))
213 if MediaType.TRACK in media_types:
214 tracks = tg.create_task(self._search_track(search_query, limit))
215 if MediaType.PLAYLIST in media_types:
216 playlists = tg.create_task(self._search_playlist(search_query, limit))
217
218 search_results = SearchResults()
219
220 if artists:
221 search_results.artists = artists.result()
222 if albums:
223 search_results.albums = albums.result()
224 if tracks:
225 search_results.tracks = tracks.result()
226 if playlists:
227 search_results.playlists = playlists.result()
228
229 return search_results
230
231 async def get_library_artists(self) -> AsyncGenerator[Artist]:
232 """Retrieve all library artists from Jellyfin Music."""
233 jellyfin_libraries = await self._get_music_libraries()
234 for jellyfin_library in jellyfin_libraries:
235 stream = (
236 self._client.artists.parent(jellyfin_library[ITEM_KEY_ID])
237 .enable_userdata()
238 .fields(*ARTIST_FIELDS)
239 .stream(100)
240 )
241 async for artist in stream:
242 yield parse_artist(self.logger, self.instance_id, self._client, artist)
243
244 async def get_library_albums(self) -> AsyncGenerator[Album]:
245 """Retrieve all library albums from Jellyfin Music."""
246 jellyfin_libraries = await self._get_music_libraries()
247 for jellyfin_library in jellyfin_libraries:
248 stream = (
249 self._client.albums.parent(jellyfin_library[ITEM_KEY_ID])
250 .enable_userdata()
251 .fields(*ALBUM_FIELDS)
252 .stream(100)
253 )
254 async for album in stream:
255 yield parse_album(self.logger, self.instance_id, self._client, album)
256
257 async def get_library_tracks(self) -> AsyncGenerator[Track]:
258 """Retrieve library tracks from Jellyfin Music."""
259 jellyfin_libraries = await self._get_music_libraries()
260 for jellyfin_library in jellyfin_libraries:
261 stream = (
262 self._client.tracks.parent(jellyfin_library[ITEM_KEY_ID])
263 .enable_userdata()
264 .fields(*TRACK_FIELDS)
265 .stream(100)
266 )
267 async for track in stream:
268 if not len(track[ITEM_KEY_MEDIA_STREAMS]):
269 self.logger.warning(
270 "Invalid track %s: Does not have any media streams", track[ITEM_KEY_NAME]
271 )
272 continue
273 yield parse_track(self.logger, self.instance_id, self._client, track)
274
275 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
276 """Retrieve all library playlists from the provider."""
277 playlist_libraries = await self._get_playlists()
278 for playlist_library in playlist_libraries:
279 stream = (
280 self._client.playlists.parent(playlist_library[ITEM_KEY_ID])
281 .enable_userdata()
282 .stream(100)
283 )
284 async for playlist in stream:
285 if ITEM_KEY_MEDIA_TYPE in playlist: # Only jellyfin has this property
286 if playlist[ITEM_KEY_MEDIA_TYPE] == MEDIA_TYPE_AUDIO:
287 yield parse_playlist(self.instance_id, self._client, playlist)
288 else: # emby playlists are only audio type
289 yield parse_playlist(self.instance_id, self._client, playlist)
290
291 async def get_album(self, prov_album_id: str) -> Album:
292 """Get full album details by id."""
293 try:
294 album = await self._client.get_album(prov_album_id)
295 except NotFound:
296 raise MediaNotFoundError(f"Item {prov_album_id} not found")
297 return parse_album(self.logger, self.instance_id, self._client, album)
298
299 @use_cache(3600) # Cache for 1 hour
300 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
301 """Get album tracks for given album id."""
302 jellyfin_album_tracks = (
303 await self._client.tracks.parent(prov_album_id)
304 .enable_userdata()
305 .fields(*TRACK_FIELDS)
306 .request()
307 )
308 return [
309 parse_track(self.logger, self.instance_id, self._client, jellyfin_album_track)
310 for jellyfin_album_track in jellyfin_album_tracks["Items"]
311 ]
312
313 @use_cache(60 * 15) # Cache for 15 minutes
314 async def get_artist(self, prov_artist_id: str) -> Artist:
315 """Get full artist details by id."""
316 if prov_artist_id == UNKNOWN_ARTIST:
317 artist = Artist(
318 item_id=UNKNOWN_ARTIST,
319 name=UNKNOWN_ARTIST,
320 provider=self.instance_id,
321 provider_mappings={
322 ProviderMapping(
323 item_id=UNKNOWN_ARTIST,
324 provider_domain=self.domain,
325 provider_instance=self.instance_id,
326 )
327 },
328 )
329 artist.mbid = UNKNOWN_ARTIST_ID_MBID
330 return artist
331
332 try:
333 jellyfin_artist = await self._client.get_artist(prov_artist_id)
334 except NotFound:
335 raise MediaNotFoundError(f"Item {prov_artist_id} not found")
336 return parse_artist(self.logger, self.instance_id, self._client, jellyfin_artist)
337
338 @use_cache(60 * 15) # Cache for 15 minutes
339 async def get_track(self, prov_track_id: str) -> Track:
340 """Get full track details by id."""
341 try:
342 track = await self._client.get_track(prov_track_id)
343 except NotFound:
344 raise MediaNotFoundError(f"Item {prov_track_id} not found")
345 return parse_track(self.logger, self.instance_id, self._client, track)
346
347 @use_cache(60 * 15) # Cache for 15 minutes
348 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
349 """Get full playlist details by id."""
350 try:
351 playlist = await self._client.get_playlist(prov_playlist_id)
352 except NotFound:
353 raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
354 return parse_playlist(self.instance_id, self._client, playlist)
355
356 @use_cache(3600) # Cache for 1 hour
357 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
358 """Get playlist tracks."""
359 result: list[Track] = []
360 playlist_items = (
361 await self._client.tracks.in_playlist(prov_playlist_id)
362 .enable_userdata()
363 .fields(*TRACK_FIELDS)
364 .limit(100)
365 .start_index(page * 100)
366 .request()
367 )
368 for index, jellyfin_track in enumerate(playlist_items["Items"], 1):
369 pos = (page * 100) + index
370 try:
371 if track := parse_track(
372 self.logger, self.instance_id, self._client, jellyfin_track
373 ):
374 track.position = pos
375 result.append(track)
376 except (KeyError, ValueError) as err:
377 self.logger.error(
378 "Skipping track %s: %s", jellyfin_track.get(ITEM_KEY_NAME, index), str(err)
379 )
380 return result
381
382 @use_cache(3600) # Cache for 1 hour
383 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
384 """Get a list of albums for the given artist."""
385 albums = (
386 await self._client.albums.parent(prov_artist_id)
387 .fields(*ALBUM_FIELDS)
388 .enable_userdata()
389 .request()
390 )
391 return [
392 parse_album(self.logger, self.instance_id, self._client, album)
393 for album in albums["Items"]
394 ]
395
396 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
397 """Return the content details for the given track when it will be streamed."""
398 try:
399 jellyfin_track = await self._client.get_track(item_id)
400 except NotFound:
401 raise MediaNotFoundError(f"Item {item_id} not found")
402 url = self._client.audio_url(
403 jellyfin_track[ITEM_KEY_ID], container=SUPPORTED_CONTAINER_FORMATS
404 )
405 runtime_ticks = jellyfin_track.get(ITEM_KEY_RUNTIME_TICKS)
406 return StreamDetails(
407 item_id=jellyfin_track[ITEM_KEY_ID],
408 provider=self.instance_id,
409 audio_format=audio_format(jellyfin_track),
410 stream_type=StreamType.HTTP,
411 duration=(
412 int(runtime_ticks / 10000000) # 10000000 ticks per second
413 if runtime_ticks is not None
414 else None
415 ),
416 path=url,
417 can_seek=True,
418 allow_seek=True,
419 )
420
421 @use_cache(3600) # Cache for 1 hour
422 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
423 """Retrieve a dynamic list of tracks based on the provided item."""
424 resp = await self._client.get_similar_tracks(
425 prov_track_id, limit=limit, fields=TRACK_FIELDS
426 )
427 return [
428 parse_track(self.logger, self.instance_id, self._client, track)
429 for track in resp["Items"]
430 ]
431
432 async def _get_music_libraries(self) -> list[JellyMediaLibrary]:
433 """Return all supported libraries a user has access to."""
434 response = await self._client.get_media_folders()
435 libraries = response["Items"]
436 result = []
437 for library in libraries:
438 if library.get(ITEM_KEY_COLLECTION_TYPE) == COLLECTION_TYPE_MUSIC:
439 result.append(library)
440 return result
441
442 async def _get_playlists(self) -> list[JellyMediaLibrary]:
443 """Return all supported libraries a user has access to."""
444 response = await self._client.get_media_folders()
445 libraries = response["Items"]
446 result = []
447 for library in libraries:
448 if library.get(ITEM_KEY_COLLECTION_TYPE) == COLLECTION_TYPE_PLAYLISTS:
449 result.append(library)
450 return result
451