diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 6f3c41d282f..eb463d8bed0 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -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, diff --git a/tests/components/jellyfin/fixtures/sessions-new-client.json b/tests/components/jellyfin/fixtures/sessions-new-client.json index ff8ab8885ae..0a3df78e720 100644 --- a/tests/components/jellyfin/fixtures/sessions-new-client.json +++ b/tests/components/jellyfin/fixtures/sessions-new-client.json @@ -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", diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index 9a8f93dc5bd..3d22871dc22 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -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" + } + } } ] diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index 0100c7618b7..dbc02d7159a 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -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', + }), ]), }) # --- diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index b4506f5a607..6d34dc2e780 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -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 diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py index 82d42d7a27a..1459003a96d 100644 --- a/tests/components/jellyfin/test_sensor.py +++ b/tests/components/jellyfin/test_sensor.py @@ -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