Jellyfin native client controls (#161982)

This commit is contained in:
Liquidmasl
2026-02-03 20:18:59 +01:00
committed by GitHub
parent 31562e7571
commit fe363f32ec
6 changed files with 362 additions and 9 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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"
}
}
}
]

View File

@@ -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',
}),
]),
})
# ---

View File

@@ -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

View File

@@ -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