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