diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 0e63cb2f5d2..ac47bcf732f 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -11,7 +11,12 @@ from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.core import HomeAssistant from .client_wrapper import get_artwork_url -from .const import CONTENT_TYPE_MAP, MEDIA_CLASS_MAP, MEDIA_TYPE_NONE +from .const import ( + CONTENT_TYPE_MAP, + MEDIA_CLASS_MAP, + MEDIA_TYPE_NONE, + SUPPORTED_COLLECTION_TYPES, +) CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS: dict[str, str] = { MediaType.MUSIC: MediaClass.MUSIC, @@ -22,8 +27,6 @@ CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS: dict[str, str] = { "library": MediaClass.DIRECTORY, } -JF_SUPPORTED_LIBRARY_TYPES = ["movies", "music", "tvshows"] - PLAYABLE_MEDIA_TYPES = [ MediaType.EPISODE, MediaType.MOVIE, @@ -65,7 +68,7 @@ async def build_root_response( children = [ await item_payload(hass, client, user_id, folder) for folder in folders["Items"] - if folder["CollectionType"] in JF_SUPPORTED_LIBRARY_TYPES + if folder["CollectionType"] in SUPPORTED_COLLECTION_TYPES ] return BrowseMedia( diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index 865e05a0081..fb8b4f15d82 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -11,6 +11,7 @@ CLIENT_VERSION: Final = hass_version COLLECTION_TYPE_MOVIES: Final = "movies" COLLECTION_TYPE_MUSIC: Final = "music" +COLLECTION_TYPE_TVSHOWS: Final = "tvshows" CONF_CLIENT_DEVICE_ID: Final = "client_device_id" @@ -27,8 +28,11 @@ ITEM_KEY_NAME: Final = "Name" ITEM_TYPE_ALBUM: Final = "MusicAlbum" ITEM_TYPE_ARTIST: Final = "MusicArtist" ITEM_TYPE_AUDIO: Final = "Audio" +ITEM_TYPE_EPISODE: Final = "Episode" ITEM_TYPE_LIBRARY: Final = "CollectionFolder" ITEM_TYPE_MOVIE: Final = "Movie" +ITEM_TYPE_SERIES: Final = "Series" +ITEM_TYPE_SEASON: Final = "Season" MAX_IMAGE_WIDTH: Final = 500 MAX_STREAMING_BITRATE: Final = "140000000" @@ -39,7 +43,14 @@ MEDIA_TYPE_AUDIO: Final = "Audio" MEDIA_TYPE_NONE: Final = "" MEDIA_TYPE_VIDEO: Final = "Video" -SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC, COLLECTION_TYPE_MOVIES] +SUPPORTED_COLLECTION_TYPES: Final = [ + COLLECTION_TYPE_MUSIC, + COLLECTION_TYPE_MOVIES, + COLLECTION_TYPE_TVSHOWS, +] + +PLAYABLE_ITEM_TYPES: Final = [ITEM_TYPE_AUDIO, ITEM_TYPE_EPISODE, ITEM_TYPE_MOVIE] + USER_APP_NAME: Final = "Home Assistant" USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}" diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index b81b5d81445..b2e7e1468fd 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from .const import ( COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, + COLLECTION_TYPE_TVSHOWS, DOMAIN, ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_ID, @@ -32,13 +33,17 @@ from .const import ( ITEM_TYPE_ALBUM, ITEM_TYPE_ARTIST, ITEM_TYPE_AUDIO, + ITEM_TYPE_EPISODE, ITEM_TYPE_LIBRARY, ITEM_TYPE_MOVIE, + ITEM_TYPE_SEASON, + ITEM_TYPE_SERIES, MAX_IMAGE_WIDTH, MEDIA_SOURCE_KEY_PATH, MEDIA_TYPE_AUDIO, MEDIA_TYPE_NONE, MEDIA_TYPE_VIDEO, + PLAYABLE_ITEM_TYPES, SUPPORTED_COLLECTION_TYPES, ) from .models import JellyfinData @@ -100,6 +105,10 @@ class JellyfinSource(MediaSource): return await self._build_artist(media_item, True) if item_type == ITEM_TYPE_ALBUM: return await self._build_album(media_item, True) + if item_type == ITEM_TYPE_SERIES: + return await self._build_series(media_item, True) + if item_type == ITEM_TYPE_SEASON: + return await self._build_season(media_item, True) raise BrowseError(f"Unsupported item type {item_type}") @@ -146,6 +155,8 @@ class JellyfinSource(MediaSource): return await self._build_music_library(library, include_children) if collection_type == COLLECTION_TYPE_MOVIES: return await self._build_movie_library(library, include_children) + if collection_type == COLLECTION_TYPE_TVSHOWS: + return await self._build_tv_library(library, include_children) raise BrowseError(f"Unsupported collection type {collection_type}") @@ -326,6 +337,121 @@ class JellyfinSource(MediaSource): return result + async def _build_tv_library( + self, library: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single tv show library as a browsable media source.""" + library_id = library[ITEM_KEY_ID] + library_name = library[ITEM_KEY_NAME] + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=library_id, + media_class=MediaClass.DIRECTORY, + media_content_type=MEDIA_TYPE_NONE, + title=library_name, + can_play=False, + can_expand=True, + ) + + if include_children: + result.children_media_class = MediaClass.TV_SHOW + result.children = await self._build_tvshow(library_id) + + return result + + async def _build_tvshow(self, library_id: str) -> list[BrowseMediaSource]: + """Return all series in the tv library.""" + series = await self._get_children(library_id, ITEM_TYPE_SERIES) + series = sorted(series, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [await self._build_series(serie, False) for serie in series] + + async def _build_series( + self, series: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single series as a browsable media source.""" + series_id = series[ITEM_KEY_ID] + series_title = series[ITEM_KEY_NAME] + thumbnail_url = self._get_thumbnail_url(series) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=series_id, + media_class=MediaClass.TV_SHOW, + media_content_type=MEDIA_TYPE_NONE, + title=series_title, + can_play=False, + can_expand=True, + thumbnail=thumbnail_url, + ) + + if include_children: + result.children_media_class = MediaClass.SEASON + result.children = await self._build_seasons(series_id) + + return result + + async def _build_seasons(self, series_id: str) -> list[BrowseMediaSource]: + """Return all seasons in the series.""" + seasons = await self._get_children(series_id, ITEM_TYPE_SEASON) + seasons = sorted(seasons, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [await self._build_season(season, False) for season in seasons] + + async def _build_season( + self, season: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single series as a browsable media source.""" + season_id = season[ITEM_KEY_ID] + season_title = season[ITEM_KEY_NAME] + thumbnail_url = self._get_thumbnail_url(season) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=season_id, + media_class=MediaClass.TV_SHOW, + media_content_type=MEDIA_TYPE_NONE, + title=season_title, + can_play=False, + can_expand=True, + thumbnail=thumbnail_url, + ) + + if include_children: + result.children_media_class = MediaClass.EPISODE + result.children = await self._build_episodes(season_id) + + return result + + async def _build_episodes(self, season_id: str) -> list[BrowseMediaSource]: + """Return all episode in the season.""" + episodes = await self._get_children(season_id, ITEM_TYPE_EPISODE) + episodes = sorted(episodes, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [ + self._build_episode(episode) + for episode in episodes + if _media_mime_type(episode) is not None + ] + + def _build_episode(self, episode: dict[str, Any]) -> BrowseMediaSource: + """Return a single episode as a browsable media source.""" + episode_id = episode[ITEM_KEY_ID] + episode_title = episode[ITEM_KEY_NAME] + mime_type = _media_mime_type(episode) + thumbnail_url = self._get_thumbnail_url(episode) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=episode_id, + media_class=MediaClass.EPISODE, + media_content_type=mime_type, + title=episode_title, + can_play=True, + can_expand=False, + thumbnail=thumbnail_url, + ) + + return result + async def _get_children( self, parent_id: str, item_type: str ) -> list[dict[str, Any]]: @@ -335,7 +461,7 @@ class JellyfinSource(MediaSource): "ParentId": parent_id, "IncludeItemTypes": item_type, } - if item_type in {ITEM_TYPE_AUDIO, ITEM_TYPE_MOVIE}: + if item_type in PLAYABLE_ITEM_TYPES: params["Fields"] = ITEM_KEY_MEDIA_SOURCES result = await self.hass.async_add_executor_job(self.api.user_items, "", params)