From a9e7a24af296b3fd19068442b0bcb1c3d7ce5edc Mon Sep 17 00:00:00 2001 From: Chris Browet Date: Fri, 7 May 2021 19:36:17 +0200 Subject: [PATCH] Baseline media_source --- README.md | 26 ++++ __init__.py | 270 ++++++++++++++++++++++++++++++++++- const.py | 3 + device.py | 267 ----------------------------------- media_browser.py | 197 -------------------------- media_player.py | 4 +- media_source.py | 359 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 655 insertions(+), 471 deletions(-) delete mode 100644 device.py delete mode 100644 media_browser.py create mode 100644 media_source.py diff --git a/README.md b/README.md index 1b190fb..6b9956b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ # jellyfin_ha + Jellyfin integration for Home Assistant + +## Changelog + +### 1.0 + +- Initial public release + +## Features + +### Entities + +- 1 media_player entity per device +- 1 sensor per server + +### Media Browser + +- Browse medias and start playback from within Home Assistant + +### Media Source + +- Browse and stream to device (e.g. Chromecast) + +### Services + +- `trigger_scan`: Trigger a server media scan diff --git a/__init__.py b/__init__.py index a959174..e5979ab 100644 --- a/__init__.py +++ b/__init__.py @@ -31,6 +31,8 @@ from homeassistant.helpers.dispatcher import ( # pylint: disable=import-error from .const import ( DOMAIN, + USER_APP_NAME, + CLIENT_VERSION, SIGNAL_STATE_UPDATED, SERVICE_SCAN, STATE_OFF, @@ -38,15 +40,12 @@ from .const import ( STATE_PAUSED, STATE_PLAYING, ) -from .device import JellyfinDevice _LOGGER = logging.getLogger(__name__) PLATFORMS = ["sensor", "media_player"] UPDATE_UNLISTENER = None -USER_APP_NAME = "Home Assistant" -CLIENT_VERSION = "1.0" PATH_REGEX = re.compile("^(https?://)?([^/:]+)(:[0-9]+)?(/.*)?$") SCAN_SERVICE_SCHEMA = vol.Schema( @@ -143,6 +142,263 @@ async def _update_listener(hass, config_entry): await hass.config_entries.async_reload(config_entry.entry_id) +class JellyfinDevice(object): + """ Represents properties of an Jellyfin Device. """ + + def __init__(self, session, jf_manager): + """Initialize Emby device object.""" + self.jf_manager = jf_manager + self.is_active = True + self.update_data(session) + + def update_data(self, session): + """ Update session object. """ + self.session = session + + def set_active(self, active): + """ Mark device as on/off. """ + self.is_active = active + + @property + def session_raw(self): + """ Return raw session data. """ + return self.session + + @property + def session_id(self): + """ Return current session Id. """ + try: + return self.session['Id'] + except KeyError: + return None + + @property + def unique_id(self): + """ Return device id.""" + try: + return self.session['DeviceId'] + except KeyError: + return None + + @property + def name(self): + """ Return device name.""" + try: + return self.session['DeviceName'] + except KeyError: + return None + + @property + def client(self): + """ Return client name. """ + try: + return self.session['Client'] + except KeyError: + return None + + @property + def username(self): + """ Return device name.""" + try: + return self.session['UserName'] + except KeyError: + return None + + @property + def media_title(self): + """ Return title currently playing.""" + try: + return self.session['NowPlayingItem']['Name'] + except KeyError: + return None + + @property + def media_season(self): + """Season of curent playing media (TV Show only).""" + try: + return self.session['NowPlayingItem']['ParentIndexNumber'] + except KeyError: + return None + + @property + def media_series_title(self): + """The title of the series of current playing media (TV Show only).""" + try: + return self.session['NowPlayingItem']['SeriesName'] + except KeyError: + return None + + @property + def media_episode(self): + """Episode of current playing media (TV Show only).""" + try: + return self.session['NowPlayingItem']['IndexNumber'] + except KeyError: + return None + + @property + def media_album_name(self): + """Album name of current playing media (Music track only).""" + try: + return self.session['NowPlayingItem']['Album'] + except KeyError: + return None + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + try: + artists = self.session['NowPlayingItem']['Artists'] + if len(artists) > 1: + return artists[0] + else: + return artists + except KeyError: + return None + + @property + def media_album_artist(self): + """Album artist of current playing media (Music track only).""" + try: + return self.session['NowPlayingItem']['AlbumArtist'] + except KeyError: + return None + + @property + def media_id(self): + """ Return title currently playing.""" + try: + return self.session['NowPlayingItem']['Id'] + except KeyError: + return None + + @property + def media_type(self): + """ Return type currently playing.""" + try: + return self.session['NowPlayingItem']['Type'] + except KeyError: + return None + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self.is_nowplaying: + try: + image_id = self.session['NowPlayingItem']['ImageTags']['Thumb'] + image_type = 'Thumb' + except KeyError: + try: + image_id = self.session[ + 'NowPlayingItem']['ImageTags']['Primary'] + image_type = 'Primary' + except KeyError: + return None + url = self.jf_manager.api.artwork(self.media_id, image_type, 500) + return url + else: + return None + + @property + def media_position(self): + """ Return position currently playing.""" + try: + return int(self.session['PlayState']['PositionTicks']) / 10000000 + except KeyError: + return None + + @property + def media_runtime(self): + """ Return total runtime length.""" + try: + return int( + self.session['NowPlayingItem']['RunTimeTicks']) / 10000000 + except KeyError: + return None + + @property + def media_percent_played(self): + """ Return media percent played. """ + try: + return (self.media_position / self.media_runtime) * 100 + except TypeError: + return None + + @property + def state(self): + """ Return current playstate of the device. """ + if self.is_active: + if 'NowPlayingItem' in self.session: + if self.session['PlayState']['IsPaused']: + return STATE_PAUSED + else: + return STATE_PLAYING + else: + return STATE_IDLE + else: + return STATE_OFF + + @property + def is_nowplaying(self): + """ Return true if an item is currently active. """ + if self.state == 'Idle' or self.state == 'Off': + return False + else: + return True + + @property + def supports_remote_control(self): + """ Return remote control status. """ + return self.session['SupportsRemoteControl'] + + async def get_item(self, id): + return await self.jf_manager.get_item(id) + + async def get_items(self, query=None): + return await self.jf_manager.get_items(query) + + async def get_artwork(self, media_id) -> Tuple[Optional[str], Optional[str]]: + return await self.jf_manager.get_artwork(media_id) + + async def get_artwork_url(self, media_id) -> str: + return await self.jf_manager.get_artwork_url(media_id) + + async def set_playstate(self, state, pos=0): + """ Send media commands to server. """ + params = {} + if state == 'Seek': + params['SeekPositionTicks'] = int(pos * 10000000) + params['static'] = 'true' + + await self.jf_manager.set_playstate(self.session_id, state, params) + + def media_play(self): + """ Send play command to device. """ + return self.set_playstate('Unpause') + + def media_pause(self): + """ Send pause command to device. """ + return self.set_playstate('Pause') + + def media_stop(self): + """ Send stop command to device. """ + return self.set_playstate('Stop') + + def media_next(self): + """ Send next track command to device. """ + return self.set_playstate('NextTrack') + + def media_previous(self): + """ Send previous track command to device. """ + return self.set_playstate('PreviousTrack') + + def media_seek(self, position): + """ Send seek command to device. """ + return self.set_playstate('Seek', position) + + async def play_media(self, media_id): + await self.jf_manager.play_media(self.session_id, media_id) + class JellyfinClientManager(object): def __init__(self, hass: HomeAssistant, config_entry): self.hass = hass @@ -430,6 +686,9 @@ class JellyfinClientManager(object): async def trigger_scan(self): await self.hass.async_add_executor_job(self.jf_client.jellyfin._post, "Library/Refresh") + def get_server_url(self) -> str: + return self.jf_client.config.data["auth.server"] + async def get_item(self, id): return await self.hass.async_add_executor_job(self.jf_client.jellyfin.get_item, id) @@ -460,6 +719,9 @@ class JellyfinClientManager(object): return (None, None) + async def get_play_info(self, media_id, profile): + return await self.hass.async_add_executor_job(self.jf_client.jellyfin.get_play_info, media_id, profile) + async def get_artwork_url(self, media_id) -> str: return await self.hass.async_add_executor_job(self.jf_client.jellyfin.artwork, media_id, "Primary", 500) @@ -520,5 +782,3 @@ class JellyfinClientManager(object): _LOGGER.debug('Update callback %s for device %s by %s', callback, device, msg) self._event_loop.call_soon(callback, msg) - - diff --git a/const.py b/const.py index b8a9376..4b54d3f 100644 --- a/const.py +++ b/const.py @@ -4,6 +4,9 @@ DOMAIN = "jellyfin" SIGNAL_STATE_UPDATED = "{}.updated".format(DOMAIN) SERVICE_SCAN = "trigger_scan" +USER_APP_NAME = "Home Assistant" +CLIENT_VERSION = "1.0" + DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True DEFAULT_PORT = 8096 diff --git a/device.py b/device.py deleted file mode 100644 index caa4e4b..0000000 --- a/device.py +++ /dev/null @@ -1,267 +0,0 @@ -import logging -from typing import Optional, Tuple -from .const import ( - STATE_OFF, - STATE_IDLE, - STATE_PAUSED, - STATE_PLAYING, -) - -_LOGGER = logging.getLogger(__name__) - -class JellyfinDevice(object): - """ Represents properties of an Jellyfin Device. """ - - def __init__(self, session, jf_manager): - """Initialize Emby device object.""" - self.jf_manager = jf_manager - self.is_active = True - self.update_data(session) - - def update_data(self, session): - """ Update session object. """ - self.session = session - - def set_active(self, active): - """ Mark device as on/off. """ - self.is_active = active - - @property - def session_raw(self): - """ Return raw session data. """ - return self.session - - @property - def session_id(self): - """ Return current session Id. """ - try: - return self.session['Id'] - except KeyError: - return None - - @property - def unique_id(self): - """ Return device id.""" - try: - return self.session['DeviceId'] - except KeyError: - return None - - @property - def name(self): - """ Return device name.""" - try: - return self.session['DeviceName'] - except KeyError: - return None - - @property - def client(self): - """ Return client name. """ - try: - return self.session['Client'] - except KeyError: - return None - - @property - def username(self): - """ Return device name.""" - try: - return self.session['UserName'] - except KeyError: - return None - - @property - def media_title(self): - """ Return title currently playing.""" - try: - return self.session['NowPlayingItem']['Name'] - except KeyError: - return None - - @property - def media_season(self): - """Season of curent playing media (TV Show only).""" - try: - return self.session['NowPlayingItem']['ParentIndexNumber'] - except KeyError: - return None - - @property - def media_series_title(self): - """The title of the series of current playing media (TV Show only).""" - try: - return self.session['NowPlayingItem']['SeriesName'] - except KeyError: - return None - - @property - def media_episode(self): - """Episode of current playing media (TV Show only).""" - try: - return self.session['NowPlayingItem']['IndexNumber'] - except KeyError: - return None - - @property - def media_album_name(self): - """Album name of current playing media (Music track only).""" - try: - return self.session['NowPlayingItem']['Album'] - except KeyError: - return None - - @property - def media_artist(self): - """Artist of current playing media (Music track only).""" - try: - artists = self.session['NowPlayingItem']['Artists'] - if len(artists) > 1: - return artists[0] - else: - return artists - except KeyError: - return None - - @property - def media_album_artist(self): - """Album artist of current playing media (Music track only).""" - try: - return self.session['NowPlayingItem']['AlbumArtist'] - except KeyError: - return None - - @property - def media_id(self): - """ Return title currently playing.""" - try: - return self.session['NowPlayingItem']['Id'] - except KeyError: - return None - - @property - def media_type(self): - """ Return type currently playing.""" - try: - return self.session['NowPlayingItem']['Type'] - except KeyError: - return None - - @property - def media_image_url(self): - """Image url of current playing media.""" - if self.is_nowplaying: - try: - image_id = self.session['NowPlayingItem']['ImageTags']['Thumb'] - image_type = 'Thumb' - except KeyError: - try: - image_id = self.session[ - 'NowPlayingItem']['ImageTags']['Primary'] - image_type = 'Primary' - except KeyError: - return None - url = self.jf_manager.api.artwork(self.media_id, image_type, 500) - return url - else: - return None - - @property - def media_position(self): - """ Return position currently playing.""" - try: - return int(self.session['PlayState']['PositionTicks']) / 10000000 - except KeyError: - return None - - @property - def media_runtime(self): - """ Return total runtime length.""" - try: - return int( - self.session['NowPlayingItem']['RunTimeTicks']) / 10000000 - except KeyError: - return None - - @property - def media_percent_played(self): - """ Return media percent played. """ - try: - return (self.media_position / self.media_runtime) * 100 - except TypeError: - return None - - @property - def state(self): - """ Return current playstate of the device. """ - if self.is_active: - if 'NowPlayingItem' in self.session: - if self.session['PlayState']['IsPaused']: - return STATE_PAUSED - else: - return STATE_PLAYING - else: - return STATE_IDLE - else: - return STATE_OFF - - @property - def is_nowplaying(self): - """ Return true if an item is currently active. """ - if self.state == 'Idle' or self.state == 'Off': - return False - else: - return True - - @property - def supports_remote_control(self): - """ Return remote control status. """ - return self.session['SupportsRemoteControl'] - - async def get_item(self, id): - return await self.jf_manager.get_item(id) - - async def get_items(self, query=None): - return await self.jf_manager.get_items(query) - - async def get_artwork(self, media_id) -> Tuple[Optional[str], Optional[str]]: - return await self.jf_manager.get_artwork(media_id) - - async def get_artwork_url(self, media_id) -> str: - return await self.jf_manager.get_artwork_url(media_id) - - async def set_playstate(self, state, pos=0): - """ Send media commands to server. """ - params = {} - if state == 'Seek': - params['SeekPositionTicks'] = int(pos * 10000000) - params['static'] = 'true' - - await self.jf_manager.set_playstate(self.session_id, state, params) - - def media_play(self): - """ Send play command to device. """ - return self.set_playstate('Unpause') - - def media_pause(self): - """ Send pause command to device. """ - return self.set_playstate('Pause') - - def media_stop(self): - """ Send stop command to device. """ - return self.set_playstate('Stop') - - def media_next(self): - """ Send next track command to device. """ - return self.set_playstate('NextTrack') - - def media_previous(self): - """ Send previous track command to device. """ - return self.set_playstate('PreviousTrack') - - def media_seek(self, position): - """ Send seek command to device. """ - return self.set_playstate('Seek', position) - - async def play_media(self, media_id): - await self.jf_manager.play_media(self.session_id, media_id) diff --git a/media_browser.py b/media_browser.py deleted file mode 100644 index 965ea89..0000000 --- a/media_browser.py +++ /dev/null @@ -1,197 +0,0 @@ -import logging - -from homeassistant.components.media_player import BrowseError, BrowseMedia -from homeassistant.components.media_player.const import ( - MEDIA_CLASS_ALBUM, - MEDIA_CLASS_ARTIST, - MEDIA_CLASS_CHANNEL, - MEDIA_CLASS_DIRECTORY, - MEDIA_CLASS_EPISODE, - MEDIA_CLASS_MOVIE, - MEDIA_CLASS_MUSIC, - MEDIA_CLASS_PLAYLIST, - MEDIA_CLASS_SEASON, - MEDIA_CLASS_TRACK, - MEDIA_CLASS_TV_SHOW, - MEDIA_TYPE_ALBUM, - MEDIA_TYPE_ARTIST, - MEDIA_TYPE_CHANNEL, - MEDIA_TYPE_EPISODE, - MEDIA_TYPE_MOVIE, - MEDIA_TYPE_PLAYLIST, - MEDIA_TYPE_SEASON, - MEDIA_TYPE_TRACK, - MEDIA_TYPE_TVSHOW, -) - -from .device import JellyfinDevice - -PLAYABLE_MEDIA_TYPES = [ - MEDIA_TYPE_ALBUM, - MEDIA_TYPE_ARTIST, - MEDIA_TYPE_TRACK, -] - -CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS = { - MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, - MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, - MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, - MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, - MEDIA_TYPE_TVSHOW: MEDIA_CLASS_TV_SHOW, -} - -CHILD_TYPE_MEDIA_CLASS = { - MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, - MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, - MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, - MEDIA_TYPE_MOVIE: MEDIA_CLASS_MOVIE, - MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, - MEDIA_TYPE_TRACK: MEDIA_CLASS_TRACK, - MEDIA_TYPE_TVSHOW: MEDIA_CLASS_TV_SHOW, - MEDIA_TYPE_CHANNEL: MEDIA_CLASS_CHANNEL, - MEDIA_TYPE_EPISODE: MEDIA_CLASS_EPISODE, -} - -_LOGGER = logging.getLogger(__name__) - -class UnknownMediaType(BrowseError): - """Unknown media type.""" - -def Type2Mediatype(type): - switcher = { - "Movie": MEDIA_TYPE_MOVIE, - "Series": MEDIA_TYPE_TVSHOW, - "Season": MEDIA_TYPE_SEASON, - "Episode": MEDIA_TYPE_EPISODE, - "Music": MEDIA_TYPE_ALBUM, - "Audio": MEDIA_TYPE_TRACK, - "BoxSet": MEDIA_CLASS_DIRECTORY, - "Folder": MEDIA_CLASS_DIRECTORY, - "CollectionFolder": MEDIA_CLASS_DIRECTORY, - "Playlist": MEDIA_CLASS_DIRECTORY, - "MusicArtist": MEDIA_TYPE_ARTIST, - "MusicAlbum": MEDIA_TYPE_ALBUM, - "Audio": MEDIA_TYPE_TRACK, - } - return switcher[type] - -def Type2Mediaclass(type): - switcher = { - "Movie": MEDIA_CLASS_MOVIE, - "Series": MEDIA_CLASS_TV_SHOW, - "Season": MEDIA_CLASS_SEASON, - "Episode": MEDIA_CLASS_EPISODE, - "Music": MEDIA_CLASS_DIRECTORY, - "BoxSet": MEDIA_CLASS_DIRECTORY, - "Folder": MEDIA_CLASS_DIRECTORY, - "CollectionFolder": MEDIA_CLASS_DIRECTORY, - "Playlist": MEDIA_CLASS_DIRECTORY, - "MusicArtist": MEDIA_CLASS_ARTIST, - "MusicAlbum": MEDIA_CLASS_ALBUM, - "Audio": MEDIA_CLASS_TRACK, - } - return switcher[type] - -def IsPlayable(type): - switcher = { - "Movie": True, - "Series": True, - "Season": True, - "Episode": True, - "Music": False, - "BoxSet": True, - "Folder": False, - "CollectionFolder": False, - "Playlist": True, - "MusicArtist": True, - "MusicAlbum": True, - "Audio": True, - } - return switcher[type] - -async def library_items(device: JellyfinDevice, media_content_type=None, media_content_id=None): - """ - Create response payload to describe contents of a specific library. - - Used by async_browse_media. - """ - - library_info = None - query = None - - if media_content_type in [None, "library"]: - library_info = BrowseMedia( - media_class=MEDIA_CLASS_DIRECTORY, - media_content_id="library", - media_content_type="library", - title="Media Library", - can_play=False, - can_expand=True, - children=[], - ) - elif media_content_type in [MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_ARTIST, MEDIA_TYPE_ALBUM, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_SEASON]: - query = { - "ParentId": media_content_id - } - - parent_item = await device.get_item(media_content_id) - library_info = BrowseMedia( - media_class=media_content_type, - media_content_id=media_content_id, - media_content_type=media_content_type, - title=parent_item["Name"], - can_play=IsPlayable(parent_item["Type"]), - can_expand=True, - thumbnail=await device.get_artwork_url(media_content_id), - children=[], - ) - else: - query = { - "Id": media_content_id - } - library_info = BrowseMedia( - media_class=MEDIA_CLASS_DIRECTORY, - media_content_id=media_content_id, - media_content_type=media_content_type, - title="", - can_play=True, - can_expand=False, - thumbnail=await device.get_artwork_url(media_content_id), - children=[], - ) - - items = await device.get_items(query) - for item in items: - if media_content_type in [None, "library", MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_ARTIST, MEDIA_TYPE_ALBUM, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_SEASON]: - if item["IsFolder"]: - library_info.children.append(BrowseMedia( - media_class=Type2Mediaclass(item["Type"]), - media_content_id=item["Id"], - media_content_type=Type2Mediatype(item["Type"]), - title=item["Name"], - can_play=IsPlayable(item["Type"]), - can_expand=True, - children=[], - thumbnail=await device.get_artwork_url(item["Id"]) - )) - else: - library_info.children.append(BrowseMedia( - media_class=Type2Mediaclass(item["Type"]), - media_content_id=item["Id"], - media_content_type=Type2Mediatype(item["Type"]), - title=item["Name"], - can_play=IsPlayable(item["Type"]), - can_expand=False, - children=[], - thumbnail=await device.get_artwork_url(item["Id"]) - )) - else: - library_info.title = item["Name"] - library_info.media_content_id = item["Id"], - library_info.media_content_type = Type2Mediatype(item["Type"]) - library_info.media_class = Type2Mediaclass(item["Type"]) - library_info.can_expand = False - library_info.can_play=IsPlayable(item["Type"]), - break - - return library_info diff --git a/media_player.py b/media_player.py index 01c4a00..f18fbf0 100644 --- a/media_player.py +++ b/media_player.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util from . import JellyfinClientManager, autolog -from .media_browser import library_items +from .media_source import async_library_items from .const import ( DOMAIN, @@ -167,7 +167,7 @@ class JellyfinMediaPlayer(MediaPlayerEntity): async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the media source.""" _LOGGER.debug("-- async_browse_media: %s / %s", media_content_type, media_content_id) - return await library_items(self.device, media_content_type, media_content_id) + return await async_library_items(self.jelly_cm, media_content_type, media_content_id) @property diff --git a/media_source.py b/media_source.py new file mode 100644 index 0000000..ff2f46e --- /dev/null +++ b/media_source.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import logging +from typing import Tuple + +from homeassistant.components.media_source.error import MediaSourceError, Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.components.media_player import BrowseError, BrowseMedia +from homeassistant.components.media_source.const import MEDIA_MIME_TYPES, URI_SCHEME + +from homeassistant.const import ( # pylint: disable=import-error + CONF_URL, +) +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_ALBUM, + MEDIA_CLASS_ARTIST, + MEDIA_CLASS_CHANNEL, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_EPISODE, + MEDIA_CLASS_MOVIE, + MEDIA_CLASS_MUSIC, + MEDIA_CLASS_PLAYLIST, + MEDIA_CLASS_SEASON, + MEDIA_CLASS_TRACK, + MEDIA_CLASS_TV_SHOW, + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_EPISODE, + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_SEASON, + MEDIA_TYPE_TRACK, + MEDIA_TYPE_TVSHOW, +) + +from . import JellyfinClientManager, JellyfinDevice, autolog + +from .const import ( + DOMAIN, + USER_APP_NAME, +) + +PLAYABLE_MEDIA_TYPES = [ + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_TRACK, +] + +CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS = { + MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, + MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, + MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, + MEDIA_TYPE_TVSHOW: MEDIA_CLASS_TV_SHOW, +} + +CHILD_TYPE_MEDIA_CLASS = { + MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, + MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, + MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, + MEDIA_TYPE_MOVIE: MEDIA_CLASS_MOVIE, + MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_TRACK: MEDIA_CLASS_TRACK, + MEDIA_TYPE_TVSHOW: MEDIA_CLASS_TV_SHOW, + MEDIA_TYPE_CHANNEL: MEDIA_CLASS_CHANNEL, + MEDIA_TYPE_EPISODE: MEDIA_CLASS_EPISODE, +} + +IDENTIFIER_SPLIT = "~~" + +_LOGGER = logging.getLogger(__name__) + +class UnknownMediaType(BrowseError): + """Unknown media type.""" + +async def async_get_media_source(hass: HomeAssistant): + """Set up Netatmo media source.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + jelly_cm: JellyfinClientManager = hass.data[DOMAIN][entry.data[CONF_URL]]["manager"] + return JellyfinSource(hass, jelly_cm) + +class JellyfinSource(MediaSource): + """Media source for Jellyfin""" + + def __init__(self, hass: HomeAssistant, manager: JellyfinClientManager): + """Initialize Netatmo source.""" + super().__init__(DOMAIN) + self.hass = hass + self.jelly_cm = manager + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve a media item to a playable item.""" + autolog("<<<") + + if not item or not item.identifier: + return None + + _, media_content_id = parse_mediasource_identifier(item.identifier) + + profile = { + "Name": USER_APP_NAME, + "MaxStreamingBitrate": 25000 * 1000, + "MusicStreamingTranscodingBitrate": 1280000, + "TimelineOffsetSeconds": 5, + "TranscodingProfiles": [ + {"Type": "Audio"}, + { + "Container": "mp4", + "Type": "Video", + "Protocol": "http", + "AudioCodec": "aac,mp3,opus,flac,vorbis", + "VideoCodec": "h264,mpeg4,mpeg2video", + "MaxAudioChannels": "6", + }, + {"Container": "jpeg", "Type": "Photo"}, + ], + "DirectPlayProfiles": [], + "ResponseProfiles": [], + "ContainerProfiles": [], + "CodecProfiles": [], + "SubtitleProfiles": [ + {"Format": "srt", "Method": "External"}, + {"Format": "srt", "Method": "Embed"}, + {"Format": "ass", "Method": "External"}, + {"Format": "ass", "Method": "Embed"}, + {"Format": "sub", "Method": "Embed"}, + {"Format": "sub", "Method": "External"}, + {"Format": "ssa", "Method": "Embed"}, + {"Format": "ssa", "Method": "External"}, + {"Format": "smi", "Method": "Embed"}, + {"Format": "smi", "Method": "External"}, + # Jellyfin currently refuses to serve these subtitle types as external. + {"Format": "pgssub", "Method": "Embed"}, + # { + # "Format": "pgssub", + # "Method": "External" + # }, + {"Format": "dvdsub", "Method": "Embed"}, + # { + # "Format": "dvdsub", + # "Method": "External" + # }, + {"Format": "pgs", "Method": "Embed"}, + # { + # "Format": "pgs", + # "Method": "External" + # } + ], + } + + playback_info = await self.jelly_cm.get_play_info(media_content_id, profile) + _LOGGER.debug("playbackinfo: %s", str(playback_info)) + + selected = None + weight_selected = 0 + for media_source in playback_info["MediaSources"]: + weight = (media_source.get("SupportsDirectPlay") or 0) * 50000 + ( + media_source.get("Bitrate") or 0 + ) / 1000 + if weight > weight_selected: + weight_selected = weight + selected = media_source + + if selected["SupportsTranscoding"]: + url = self.jelly_cm.get_server_url() + selected.get("TranscodingUrl") + _LOGGER.debug("cast url: %s", url) + return PlayMedia(url, "video/mp4") + + return None + + async def async_browse_media( + self, item: MediaSourceItem, media_types: Tuple[str] = MEDIA_MIME_TYPES + ) -> BrowseMediaSource: + """Browse media.""" + autolog("<<<") + + media_contant_type, media_content_id = async_parse_identifier(item) + return await async_library_items(self.jelly_cm, media_contant_type, media_content_id) + +@callback +def async_parse_identifier( + item: MediaSourceItem, +) -> tuple[str | None, str | None]: + """Parse identifier.""" + if not item.identifier: + # Empty source_dir_id and location + return None, None + + return item.identifier, item.identifier + +def Type2Mediatype(type): + switcher = { + "Movie": MEDIA_TYPE_MOVIE, + "Series": MEDIA_TYPE_TVSHOW, + "Season": MEDIA_TYPE_SEASON, + "Episode": MEDIA_TYPE_EPISODE, + "Music": MEDIA_TYPE_ALBUM, + "Audio": MEDIA_TYPE_TRACK, + "BoxSet": MEDIA_CLASS_DIRECTORY, + "Folder": MEDIA_CLASS_DIRECTORY, + "CollectionFolder": MEDIA_CLASS_DIRECTORY, + "Playlist": MEDIA_CLASS_DIRECTORY, + "MusicArtist": MEDIA_TYPE_ARTIST, + "MusicAlbum": MEDIA_TYPE_ALBUM, + "Audio": MEDIA_TYPE_TRACK, + } + return switcher[type] + +def Type2Mediaclass(type): + switcher = { + "Movie": MEDIA_CLASS_MOVIE, + "Series": MEDIA_CLASS_TV_SHOW, + "Season": MEDIA_CLASS_SEASON, + "Episode": MEDIA_CLASS_EPISODE, + "Music": MEDIA_CLASS_DIRECTORY, + "BoxSet": MEDIA_CLASS_DIRECTORY, + "Folder": MEDIA_CLASS_DIRECTORY, + "CollectionFolder": MEDIA_CLASS_DIRECTORY, + "Playlist": MEDIA_CLASS_DIRECTORY, + "MusicArtist": MEDIA_CLASS_ARTIST, + "MusicAlbum": MEDIA_CLASS_ALBUM, + "Audio": MEDIA_CLASS_TRACK, + } + return switcher[type] + +def IsPlayable(type): + switcher = { + "Movie": True, + "Series": True, + "Season": True, + "Episode": True, + "Music": False, + "BoxSet": True, + "Folder": False, + "CollectionFolder": False, + "Playlist": True, + "MusicArtist": True, + "MusicAlbum": True, + "Audio": True, + } + return switcher[type] + +def parse_mediasource_identifier(identifier: str): + prefix = f"{URI_SCHEME}{DOMAIN}/" + text = identifier + if identifier.startswith(prefix): + text = identifier[len(prefix):] + + return text.split(IDENTIFIER_SPLIT, 2) + +async def async_library_items(jelly_cm: JellyfinClientManager, media_content_type_in=None, media_content_id_in=None) -> BrowseMediaSource: + """ + Create response payload to describe contents of a specific library. + + Used by async_browse_media. + """ + _LOGGER.debug(f'>> async_library_items: {media_content_id_in}') + + library_info = None + query = None + + if (media_content_type_in is None): + media_content_type = None + media_content_id = None + else: + media_content_type, media_content_id = parse_mediasource_identifier(media_content_id_in) + _LOGGER.debug(f'>> {media_content_type} / {media_content_id}') + + if media_content_type in [None, "library"]: + library_info = BrowseMediaSource( + domain=DOMAIN, + identifier=f'library{IDENTIFIER_SPLIT}library', + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type="library", + title="Media Library", + can_play=False, + can_expand=True, + children=[], + ) + elif media_content_type in [MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_ARTIST, MEDIA_TYPE_ALBUM, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_SEASON]: + query = { + "ParentId": media_content_id + } + + parent_item = await jelly_cm.get_item(media_content_id) + library_info = BrowseMediaSource( + domain=DOMAIN, + identifier=f'{media_content_type}{IDENTIFIER_SPLIT}{media_content_id}', + media_class=media_content_type, + media_content_type=media_content_type, + title=parent_item["Name"], + can_play=IsPlayable(parent_item["Type"]), + can_expand=True, + thumbnail=await jelly_cm.get_artwork_url(media_content_id), + children=[], + ) + else: + query = { + "Id": media_content_id + } + library_info = BrowseMediaSource( + domain=DOMAIN, + identifier=f'{media_content_type}{IDENTIFIER_SPLIT}{media_content_id}', + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=media_content_type, + title="", + can_play=True, + can_expand=False, + thumbnail=await jelly_cm.get_artwork_url(media_content_id), + children=[], + ) + + items = await jelly_cm.get_items(query) + for item in items: + if media_content_type in [None, "library", MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_ARTIST, MEDIA_TYPE_ALBUM, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_SEASON]: + if item["IsFolder"]: + library_info.children.append(BrowseMediaSource( + domain=DOMAIN, + identifier=f'{Type2Mediatype(item["Type"])}{IDENTIFIER_SPLIT}{item["Id"]}', + media_class=Type2Mediaclass(item["Type"]), + media_content_type=Type2Mediatype(item["Type"]), + title=item["Name"], + can_play=IsPlayable(item["Type"]), + can_expand=True, + children=[], + thumbnail=await jelly_cm.get_artwork_url(item["Id"]) + )) + else: + library_info.children.append(BrowseMediaSource( + domain=DOMAIN, + identifier=f'{Type2Mediatype(item["Type"])}{IDENTIFIER_SPLIT}{item["Id"]}', + media_class=Type2Mediaclass(item["Type"]), + media_content_type=Type2Mediatype(item["Type"]), + title=item["Name"], + can_play=IsPlayable(item["Type"]), + can_expand=False, + children=[], + thumbnail=await jelly_cm.get_artwork_url(item["Id"]) + )) + else: + library_info.domain=DOMAIN + library_info.identifier=f'{Type2Mediatype(item["Type"])}{IDENTIFIER_SPLIT}{item["Id"]}', + library_info.title = item["Name"] + library_info.media_content_type = Type2Mediatype(item["Type"]) + library_info.media_class = Type2Mediaclass(item["Type"]) + library_info.can_expand = False + library_info.can_play=IsPlayable(item["Type"]), + break + + return library_info +