music-assistant-server

18.7 KBPY
__init__.py
18.7 KB497 lines • python
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