mirror of
https://github.com/home-assistant/core.git
synced 2026-02-03 22:05:35 +01:00
Jellyfin native client controls (#161982)
This commit is contained in:
@@ -150,7 +150,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
|
||||
self._attr_state = state
|
||||
self._attr_is_volume_muted = volume_muted
|
||||
self._attr_volume_level = volume_level
|
||||
# Only update volume_level if the API provides it, otherwise preserve current value
|
||||
if volume_level is not None:
|
||||
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
|
||||
@@ -190,7 +192,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
)
|
||||
features = MediaPlayerEntityFeature(0)
|
||||
|
||||
if "PlayMediaSource" in commands:
|
||||
if "PlayMediaSource" in commands or self.capabilities.get(
|
||||
"SupportsMediaControl", False
|
||||
):
|
||||
features |= (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -201,10 +205,10 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SEARCH_MEDIA
|
||||
)
|
||||
|
||||
if "Mute" in commands:
|
||||
if "Mute" in commands and "Unmute" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
if "VolumeSet" in commands:
|
||||
if "VolumeSet" in commands or "SetVolume" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
return features
|
||||
@@ -219,11 +223,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send pause command."""
|
||||
self.coordinator.api_client.jellyfin.remote_pause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PAUSED
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
self.coordinator.api_client.jellyfin.remote_unpause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play_pause(self) -> None:
|
||||
"""Send the PlayPause command to the session."""
|
||||
@@ -233,6 +239,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send stop command."""
|
||||
self.coordinator.api_client.jellyfin.remote_stop(self.session_id)
|
||||
self._attr_state = MediaPlayerState.IDLE
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
@@ -247,6 +254,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_set_volume(
|
||||
self.session_id, int(volume * 100)
|
||||
)
|
||||
self._attr_volume_level = volume
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
@@ -254,6 +263,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_mute(self.session_id)
|
||||
else:
|
||||
self.coordinator.api_client.jellyfin.remote_unmute(self.session_id)
|
||||
self._attr_is_volume_muted = mute
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -1781,7 +1781,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "PlayMediaSource"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -1781,7 +1781,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -4548,5 +4548,133 @@
|
||||
"PlayMediaSource",
|
||||
"PlayTrailers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"PlayState": {
|
||||
"PositionTicks": 100000000,
|
||||
"CanSeek": true,
|
||||
"IsPaused": false,
|
||||
"IsMuted": false,
|
||||
"VolumeLevel": 50,
|
||||
"AudioStreamIndex": 0,
|
||||
"SubtitleStreamIndex": 0,
|
||||
"MediaSourceId": "string",
|
||||
"PlayMethod": "Transcode",
|
||||
"RepeatMode": "RepeatNone",
|
||||
"LiveStreamId": "string"
|
||||
},
|
||||
"AdditionalUsers": [],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["Mute", "VolumeSet", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": false,
|
||||
"MessageCallbackUrl": "string",
|
||||
"SupportsPersistentIdentifier": false,
|
||||
"SupportsSync": false,
|
||||
"DeviceProfile": null
|
||||
},
|
||||
"RemoteEndPoint": "192.168.1.1",
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"Id": "SESSION-UUID-FIVE",
|
||||
"UserId": "USER-UUID",
|
||||
"UserName": "USER",
|
||||
"Client": "Test Client Five",
|
||||
"LastActivityDate": "2021-05-21T06:09:06.919Z",
|
||||
"LastPlaybackCheckIn": "2021-05-21T06:09:06.919Z",
|
||||
"DeviceName": "JELLYFIN-DEVICE-FIVE",
|
||||
"DeviceId": "DEVICE-UUID-FIVE",
|
||||
"ApplicationVersion": "1.0.0",
|
||||
"IsActive": true,
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsRemoteControl": true,
|
||||
"HasCustomDeviceName": false,
|
||||
"ServerId": "SERVER-UUID",
|
||||
"SupportedCommands": ["MoveUp"],
|
||||
"NowPlayingItem": {
|
||||
"Name": "TEST VIDEO",
|
||||
"ServerId": "SERVER-UUID",
|
||||
"Id": "VIDEO-UUID-FIVE",
|
||||
"RunTimeTicks": 600000000,
|
||||
"Type": "Episode",
|
||||
"UserData": {
|
||||
"PlaybackPositionTicks": 0,
|
||||
"PlayCount": 0,
|
||||
"IsFavorite": false,
|
||||
"Played": false,
|
||||
"Key": "string"
|
||||
},
|
||||
"PrimaryImageAspectRatio": 1,
|
||||
"SeriesName": "TEST SERIES",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": 1,
|
||||
"ImageTags": {
|
||||
"Primary": "tag"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"PlayState": {
|
||||
"PositionTicks": 100000000,
|
||||
"CanSeek": true,
|
||||
"IsPaused": false,
|
||||
"IsMuted": false,
|
||||
"VolumeLevel": 50,
|
||||
"AudioStreamIndex": 0,
|
||||
"SubtitleStreamIndex": 0,
|
||||
"MediaSourceId": "string",
|
||||
"PlayMethod": "Transcode",
|
||||
"RepeatMode": "RepeatNone",
|
||||
"LiveStreamId": "string"
|
||||
},
|
||||
"AdditionalUsers": [],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["Unmute", "VolumeSet", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": false,
|
||||
"MessageCallbackUrl": "string",
|
||||
"SupportsPersistentIdentifier": false,
|
||||
"SupportsSync": false,
|
||||
"DeviceProfile": null
|
||||
},
|
||||
"RemoteEndPoint": "192.168.1.1",
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"Id": "SESSION-UUID-SIX",
|
||||
"UserId": "USER-UUID",
|
||||
"UserName": "USER",
|
||||
"Client": "Test Client Six",
|
||||
"LastActivityDate": "2021-05-21T06:09:06.919Z",
|
||||
"LastPlaybackCheckIn": "2021-05-21T06:09:06.919Z",
|
||||
"DeviceName": "JELLYFIN-DEVICE-SIX",
|
||||
"DeviceId": "DEVICE-UUID-SIX",
|
||||
"ApplicationVersion": "1.0.0",
|
||||
"IsActive": true,
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsRemoteControl": true,
|
||||
"HasCustomDeviceName": false,
|
||||
"ServerId": "SERVER-UUID",
|
||||
"SupportedCommands": ["MoveUp"],
|
||||
"NowPlayingItem": {
|
||||
"Name": "TEST VIDEO",
|
||||
"ServerId": "SERVER-UUID",
|
||||
"Id": "VIDEO-UUID-SIX",
|
||||
"RunTimeTicks": 600000000,
|
||||
"Type": "Episode",
|
||||
"UserData": {
|
||||
"PlaybackPositionTicks": 0,
|
||||
"PlayCount": 0,
|
||||
"IsFavorite": false,
|
||||
"Played": false,
|
||||
"Key": "string"
|
||||
},
|
||||
"PrimaryImageAspectRatio": 1,
|
||||
"SeriesName": "TEST SERIES",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": 1,
|
||||
"ImageTags": {
|
||||
"Primary": "tag"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
'SupportedCommands': list([
|
||||
'VolumeSet',
|
||||
'Mute',
|
||||
'Unmute',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': True,
|
||||
@@ -902,6 +903,7 @@
|
||||
'SupportedCommands': list([
|
||||
'VolumeSet',
|
||||
'Mute',
|
||||
'Unmute',
|
||||
]),
|
||||
'SupportsContentUploading': True,
|
||||
'SupportsMediaControl': True,
|
||||
@@ -1785,6 +1787,122 @@
|
||||
}),
|
||||
'user_id': 'USER-UUID-TWO',
|
||||
}),
|
||||
dict({
|
||||
'capabilities': dict({
|
||||
'DeviceProfile': None,
|
||||
'MessageCallbackUrl': 'string',
|
||||
'PlayableMediaTypes': list([
|
||||
'Video',
|
||||
]),
|
||||
'SupportedCommands': list([
|
||||
'Mute',
|
||||
'VolumeSet',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': False,
|
||||
'SupportsMediaControl': True,
|
||||
'SupportsPersistentIdentifier': False,
|
||||
'SupportsSync': False,
|
||||
}),
|
||||
'client_name': 'Test Client Five',
|
||||
'client_version': '1.0.0',
|
||||
'device_id': 'DEVICE-UUID-FIVE',
|
||||
'device_name': 'JELLYFIN-DEVICE-FIVE',
|
||||
'id': 'SESSION-UUID-FIVE',
|
||||
'now_playing': dict({
|
||||
'Id': 'VIDEO-UUID-FIVE',
|
||||
'ImageTags': dict({
|
||||
'Primary': 'tag',
|
||||
}),
|
||||
'IndexNumber': 1,
|
||||
'Name': 'TEST VIDEO',
|
||||
'ParentIndexNumber': 1,
|
||||
'PrimaryImageAspectRatio': 1,
|
||||
'RunTimeTicks': 600000000,
|
||||
'SeriesName': 'TEST SERIES',
|
||||
'ServerId': 'SERVER-UUID',
|
||||
'Type': 'Episode',
|
||||
'UserData': dict({
|
||||
'IsFavorite': False,
|
||||
'Key': 'string',
|
||||
'PlayCount': 0,
|
||||
'PlaybackPositionTicks': 0,
|
||||
'Played': False,
|
||||
}),
|
||||
}),
|
||||
'play_state': dict({
|
||||
'AudioStreamIndex': 0,
|
||||
'CanSeek': True,
|
||||
'IsMuted': False,
|
||||
'IsPaused': False,
|
||||
'LiveStreamId': 'string',
|
||||
'MediaSourceId': 'string',
|
||||
'PlayMethod': 'Transcode',
|
||||
'PositionTicks': 100000000,
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'SubtitleStreamIndex': 0,
|
||||
'VolumeLevel': 50,
|
||||
}),
|
||||
'user_id': 'USER-UUID',
|
||||
}),
|
||||
dict({
|
||||
'capabilities': dict({
|
||||
'DeviceProfile': None,
|
||||
'MessageCallbackUrl': 'string',
|
||||
'PlayableMediaTypes': list([
|
||||
'Video',
|
||||
]),
|
||||
'SupportedCommands': list([
|
||||
'Unmute',
|
||||
'VolumeSet',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': False,
|
||||
'SupportsMediaControl': True,
|
||||
'SupportsPersistentIdentifier': False,
|
||||
'SupportsSync': False,
|
||||
}),
|
||||
'client_name': 'Test Client Six',
|
||||
'client_version': '1.0.0',
|
||||
'device_id': 'DEVICE-UUID-SIX',
|
||||
'device_name': 'JELLYFIN-DEVICE-SIX',
|
||||
'id': 'SESSION-UUID-SIX',
|
||||
'now_playing': dict({
|
||||
'Id': 'VIDEO-UUID-SIX',
|
||||
'ImageTags': dict({
|
||||
'Primary': 'tag',
|
||||
}),
|
||||
'IndexNumber': 1,
|
||||
'Name': 'TEST VIDEO',
|
||||
'ParentIndexNumber': 1,
|
||||
'PrimaryImageAspectRatio': 1,
|
||||
'RunTimeTicks': 600000000,
|
||||
'SeriesName': 'TEST SERIES',
|
||||
'ServerId': 'SERVER-UUID',
|
||||
'Type': 'Episode',
|
||||
'UserData': dict({
|
||||
'IsFavorite': False,
|
||||
'Key': 'string',
|
||||
'PlayCount': 0,
|
||||
'PlaybackPositionTicks': 0,
|
||||
'Played': False,
|
||||
}),
|
||||
}),
|
||||
'play_state': dict({
|
||||
'AudioStreamIndex': 0,
|
||||
'CanSeek': True,
|
||||
'IsMuted': False,
|
||||
'IsPaused': False,
|
||||
'LiveStreamId': 'string',
|
||||
'MediaSourceId': 'string',
|
||||
'PlayMethod': 'Transcode',
|
||||
'PositionTicks': 100000000,
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'SubtitleStreamIndex': 0,
|
||||
'VolumeLevel': 50,
|
||||
}),
|
||||
'user_id': 'USER-UUID',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MP_DOMAIN,
|
||||
MediaClass,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
@@ -423,3 +424,98 @@ async def test_new_client_connected(
|
||||
|
||||
state = hass.states.get("media_player.jellyfin_device_five")
|
||||
assert state
|
||||
|
||||
|
||||
async def test_supports_media_control_fallback(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that SupportsMediaControl enables controls without PlayMediaSource."""
|
||||
# SESSION-UUID-TWO has SupportsMediaControl: true but no PlayMediaSource command
|
||||
state = hass.states.get("media_player.jellyfin_device_two")
|
||||
|
||||
assert state
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
|
||||
entry = entity_registry.async_get(state.entity_id)
|
||||
assert entry
|
||||
|
||||
# Get the entity to check supported features
|
||||
entity = hass.data["entity_components"]["media_player"].get_entity(state.entity_id)
|
||||
features = entity.supported_features
|
||||
|
||||
# Should have basic playback controls
|
||||
assert features & MediaPlayerEntityFeature.PLAY
|
||||
assert features & MediaPlayerEntityFeature.PAUSE
|
||||
assert features & MediaPlayerEntityFeature.STOP
|
||||
assert features & MediaPlayerEntityFeature.SEEK
|
||||
assert features & MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
assert features & MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
|
||||
# Should also have volume controls since it has VolumeSet, Mute, and Unmute
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
|
||||
async def test_set_volume_command_alternative(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that SetVolume command (alternative to VolumeSet) enables volume control."""
|
||||
# SESSION-UUID-FOUR has SetVolume instead of VolumeSet
|
||||
state = hass.states.get("media_player.jellyfin_device_four")
|
||||
|
||||
assert state
|
||||
|
||||
# Get the entity to check supported features
|
||||
entity = hass.data["entity_components"]["media_player"].get_entity(state.entity_id)
|
||||
features = entity.supported_features
|
||||
|
||||
# Should have volume control via SetVolume command
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
|
||||
async def test_mute_requires_both_commands(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that VOLUME_MUTE requires both Mute AND Unmute commands."""
|
||||
|
||||
# SESSION-UUID-FIVE has only Mute (no Unmute) - should NOT have VOLUME_MUTE
|
||||
state_five = hass.states.get("media_player.jellyfin_device_five")
|
||||
assert state_five
|
||||
|
||||
entity_five = hass.data["entity_components"]["media_player"].get_entity(
|
||||
state_five.entity_id
|
||||
)
|
||||
features_five = entity_five.supported_features
|
||||
|
||||
# Should NOT have mute feature
|
||||
assert not (features_five & MediaPlayerEntityFeature.VOLUME_MUTE)
|
||||
# But should still have other features
|
||||
assert features_five & MediaPlayerEntityFeature.PLAY
|
||||
assert features_five & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
# SESSION-UUID-SIX has only Unmute (no Mute) - should NOT have VOLUME_MUTE
|
||||
state_six = hass.states.get("media_player.jellyfin_device_six")
|
||||
assert state_six
|
||||
|
||||
entity_six = hass.data["entity_components"]["media_player"].get_entity(
|
||||
state_six.entity_id
|
||||
)
|
||||
features_six = entity_six.supported_features
|
||||
|
||||
# Should NOT have mute feature
|
||||
assert not (features_six & MediaPlayerEntityFeature.VOLUME_MUTE)
|
||||
# But should still have other features
|
||||
assert features_six & MediaPlayerEntityFeature.PLAY
|
||||
assert features_six & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_watching(
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER Active clients"
|
||||
assert state.attributes.get(ATTR_ICON) is None
|
||||
assert state.attributes.get(ATTR_STATE_CLASS) is None
|
||||
assert state.state == "3"
|
||||
assert state.state == "5"
|
||||
|
||||
entry = entity_registry.async_get(state.entity_id)
|
||||
assert entry
|
||||
|
||||
Reference in New Issue
Block a user