mirror of https://github.com/home-assistant/core
268 lines
9.7 KiB
Python
268 lines
9.7 KiB
Python
"""Support for the Jellyfin media player."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from homeassistant.components.media_player import (
|
|
BrowseMedia,
|
|
MediaPlayerEntity,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
MediaType,
|
|
)
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.util.dt import parse_datetime
|
|
|
|
from . import JellyfinConfigEntry
|
|
from .browse_media import build_item_response, build_root_response
|
|
from .client_wrapper import get_artwork_url
|
|
from .const import CONTENT_TYPE_MAP, LOGGER
|
|
from .coordinator import JellyfinDataUpdateCoordinator
|
|
from .entity import JellyfinClientEntity
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: JellyfinConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up Jellyfin media_player from a config entry."""
|
|
coordinator = entry.runtime_data
|
|
|
|
@callback
|
|
def handle_coordinator_update() -> None:
|
|
"""Add media player per session."""
|
|
entities: list[MediaPlayerEntity] = []
|
|
for session_id in coordinator.data:
|
|
if session_id not in coordinator.session_ids:
|
|
entity: MediaPlayerEntity = JellyfinMediaPlayer(coordinator, session_id)
|
|
LOGGER.debug("Creating media player for session: %s", session_id)
|
|
coordinator.session_ids.add(session_id)
|
|
entities.append(entity)
|
|
async_add_entities(entities)
|
|
|
|
handle_coordinator_update()
|
|
|
|
entry.async_on_unload(coordinator.async_add_listener(handle_coordinator_update))
|
|
|
|
|
|
class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
|
"""Represents a Jellyfin Player device."""
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: JellyfinDataUpdateCoordinator,
|
|
session_id: str,
|
|
) -> None:
|
|
"""Initialize the Jellyfin Media Player entity."""
|
|
super().__init__(coordinator, session_id)
|
|
self._attr_unique_id = f"{coordinator.server_id}-{session_id}"
|
|
|
|
self.now_playing: dict[str, Any] | None = self.session_data.get(
|
|
"NowPlayingItem"
|
|
)
|
|
self.play_state: dict[str, Any] | None = self.session_data.get("PlayState")
|
|
|
|
self._update_from_session_data()
|
|
|
|
@callback
|
|
def _handle_coordinator_update(self) -> None:
|
|
if self.available:
|
|
self.now_playing = self.session_data.get("NowPlayingItem")
|
|
self.play_state = self.session_data.get("PlayState")
|
|
else:
|
|
self.now_playing = None
|
|
self.play_state = None
|
|
|
|
self._update_from_session_data()
|
|
super()._handle_coordinator_update()
|
|
|
|
@callback
|
|
def _update_from_session_data(self) -> None:
|
|
"""Process session data to update entity properties."""
|
|
state = None
|
|
media_content_type = None
|
|
media_content_id = None
|
|
media_title = None
|
|
media_series_title = None
|
|
media_season = None
|
|
media_episode = None
|
|
media_album_name = None
|
|
media_album_artist = None
|
|
media_artist = None
|
|
media_track = None
|
|
media_duration = None
|
|
media_position = None
|
|
media_position_updated = None
|
|
volume_muted = False
|
|
volume_level = None
|
|
|
|
if self.available:
|
|
state = MediaPlayerState.IDLE
|
|
media_position_updated = (
|
|
parse_datetime(self.session_data["LastPlaybackCheckIn"])
|
|
if self.now_playing
|
|
else None
|
|
)
|
|
|
|
if self.now_playing is not None:
|
|
state = MediaPlayerState.PLAYING
|
|
media_content_type = CONTENT_TYPE_MAP.get(self.now_playing["Type"], None)
|
|
media_content_id = self.now_playing["Id"]
|
|
media_title = self.now_playing["Name"]
|
|
|
|
if "RunTimeTicks" in self.now_playing:
|
|
media_duration = int(self.now_playing["RunTimeTicks"] / 10000000)
|
|
|
|
if media_content_type == MediaType.EPISODE:
|
|
media_content_type = MediaType.TVSHOW
|
|
media_series_title = self.now_playing.get("SeriesName")
|
|
media_season = self.now_playing.get("ParentIndexNumber")
|
|
media_episode = self.now_playing.get("IndexNumber")
|
|
elif media_content_type == MediaType.MUSIC:
|
|
media_album_name = self.now_playing.get("Album")
|
|
media_album_artist = self.now_playing.get("AlbumArtist")
|
|
media_track = self.now_playing.get("IndexNumber")
|
|
if media_artists := self.now_playing.get("Artists"):
|
|
media_artist = str(media_artists[0])
|
|
|
|
if self.play_state is not None:
|
|
if self.play_state.get("IsPaused"):
|
|
state = MediaPlayerState.PAUSED
|
|
|
|
media_position = (
|
|
int(self.play_state["PositionTicks"] / 10000000)
|
|
if "PositionTicks" in self.play_state
|
|
else None
|
|
)
|
|
volume_muted = bool(self.play_state.get("IsMuted", False))
|
|
volume_level = (
|
|
float(self.play_state["VolumeLevel"] / 100)
|
|
if "VolumeLevel" in self.play_state
|
|
else None
|
|
)
|
|
|
|
self._attr_state = state
|
|
self._attr_is_volume_muted = volume_muted
|
|
self._attr_volume_level = volume_level
|
|
self._attr_media_content_type = media_content_type
|
|
self._attr_media_content_id = media_content_id
|
|
self._attr_media_title = media_title
|
|
self._attr_media_series_title = media_series_title
|
|
self._attr_media_season = media_season
|
|
self._attr_media_episode = media_episode
|
|
self._attr_media_album_name = media_album_name
|
|
self._attr_media_album_artist = media_album_artist
|
|
self._attr_media_artist = media_artist
|
|
self._attr_media_track = media_track
|
|
self._attr_media_duration = media_duration
|
|
self._attr_media_position = media_position
|
|
self._attr_media_position_updated_at = media_position_updated
|
|
self._attr_media_image_remotely_accessible = True
|
|
|
|
@property
|
|
def media_image_url(self) -> str | None:
|
|
"""Image url of current playing media."""
|
|
# We always need the now playing item.
|
|
# If there is none, there's also no url
|
|
if self.now_playing is None:
|
|
return None
|
|
|
|
return get_artwork_url(self.coordinator.api_client, self.now_playing, 150)
|
|
|
|
@property
|
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
|
"""Flag media player features that are supported."""
|
|
commands: list[str] = self.capabilities.get("SupportedCommands", [])
|
|
controllable = self.capabilities.get("SupportsMediaControl", False)
|
|
features = MediaPlayerEntityFeature(0)
|
|
|
|
if controllable:
|
|
features |= (
|
|
MediaPlayerEntityFeature.BROWSE_MEDIA
|
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
|
| MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.SEEK
|
|
)
|
|
|
|
if "Mute" in commands:
|
|
features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
|
|
|
if "VolumeSet" in commands:
|
|
features |= MediaPlayerEntityFeature.VOLUME_SET
|
|
|
|
return features
|
|
|
|
def media_seek(self, position: float) -> None:
|
|
"""Send seek command."""
|
|
self.coordinator.api_client.jellyfin.remote_seek(
|
|
self.session_id, int(position * 10000000)
|
|
)
|
|
|
|
def media_pause(self) -> None:
|
|
"""Send pause command."""
|
|
self.coordinator.api_client.jellyfin.remote_pause(self.session_id)
|
|
self._attr_state = MediaPlayerState.PAUSED
|
|
|
|
def media_play(self) -> None:
|
|
"""Send play command."""
|
|
self.coordinator.api_client.jellyfin.remote_unpause(self.session_id)
|
|
self._attr_state = MediaPlayerState.PLAYING
|
|
|
|
def media_play_pause(self) -> None:
|
|
"""Send the PlayPause command to the session."""
|
|
self.coordinator.api_client.jellyfin.remote_playpause(self.session_id)
|
|
|
|
def media_stop(self) -> None:
|
|
"""Send stop command."""
|
|
self.coordinator.api_client.jellyfin.remote_stop(self.session_id)
|
|
self._attr_state = MediaPlayerState.IDLE
|
|
|
|
def play_media(
|
|
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
|
) -> None:
|
|
"""Play a piece of media."""
|
|
self.coordinator.api_client.jellyfin.remote_play_media(
|
|
self.session_id, [media_id]
|
|
)
|
|
|
|
def set_volume_level(self, volume: float) -> None:
|
|
"""Set volume level, range 0..1."""
|
|
self.coordinator.api_client.jellyfin.remote_set_volume(
|
|
self.session_id, int(volume * 100)
|
|
)
|
|
|
|
def mute_volume(self, mute: bool) -> None:
|
|
"""Mute the volume."""
|
|
if mute:
|
|
self.coordinator.api_client.jellyfin.remote_mute(self.session_id)
|
|
else:
|
|
self.coordinator.api_client.jellyfin.remote_unmute(self.session_id)
|
|
|
|
async def async_browse_media(
|
|
self,
|
|
media_content_type: MediaType | str | None = None,
|
|
media_content_id: str | None = None,
|
|
) -> BrowseMedia:
|
|
"""Return a BrowseMedia instance.
|
|
|
|
The BrowseMedia instance will be used by the "media_player/browse_media" websocket command.
|
|
|
|
"""
|
|
if media_content_id is None or media_content_id == "media-source://jellyfin":
|
|
return await build_root_response(
|
|
self.hass, self.coordinator.api_client, self.coordinator.user_id
|
|
)
|
|
|
|
return await build_item_response(
|
|
self.hass,
|
|
self.coordinator.api_client,
|
|
self.coordinator.user_id,
|
|
media_content_type,
|
|
media_content_id,
|
|
)
|