Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 31 additions & 15 deletions custom_components/beatify/services/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,10 @@ async def _play_via_music_assistant(self, song: dict[str, Any]) -> bool:
_LOGGER.debug("MA URI converted: %s → %s", raw_uri, uri)
_LOGGER.debug("MA playback: %s on %s", uri, self._entity_id)

expected_title = song.get("title") or ""

# Capture state before to detect actual song change on speaker
state_before = self._hass.states.get(self._entity_id)
title_before = (
state_before.attributes.get("media_title") if state_before else None
)
position_updated_before = (
state_before.attributes.get("media_position_updated_at")
if state_before
Expand All @@ -257,10 +256,14 @@ async def _play_via_music_assistant(self, song: dict[str, Any]) -> bool:
blocking=False,
)

# Wait for the song to actually play on the speaker:
# - media_title changed (new song queued)
# - media_position is low (< 5s = song just started, not leftover from old song)
# Wait for the EXPECTED song to actually play on the speaker:
# - media_title contains expected title (not just "any change" — prevents
# race condition where a previous slow song arrives during retry)
# - media_position >= 1 (pos=0 means only queued in MA, not yet playing)
# - media_position_updated_at changed (speaker is actively reporting)
expected_lower = expected_title.lower()
if not expected_lower:
_LOGGER.warning("MA playback: no expected title — skipping title verification")
elapsed = 0.0
while elapsed < PLAYBACK_TIMEOUT:
try:
Expand All @@ -275,13 +278,16 @@ async def _play_via_music_assistant(self, song: dict[str, Any]) -> bool:
position = state.attributes.get("media_position", 0)
position_updated = state.attributes.get("media_position_updated_at")

title_changed = current_title and current_title != title_before
position_fresh = position_updated != position_updated_before
# position >= 1 means speaker is actually outputting audio
# position == 0 only means "queued" in MA, not yet playing
actually_playing = isinstance(position, (int, float)) and position >= 1
# Match expected title (substring, case-insensitive) — MA may
# append suffixes like "(Official HD Video)" or "(7″ mix)"
title_matches = (
(not expected_lower) # no title to verify — accept any
or (expected_lower in current_title.lower() if current_title else False)
)

if title_changed and position_fresh and actually_playing:
if title_matches and position_fresh and actually_playing:
_LOGGER.debug(
"MA playback confirmed after %.1fs: %s (pos=%.1f)",
elapsed,
Expand All @@ -293,14 +299,24 @@ async def _play_via_music_assistant(self, song: dict[str, Any]) -> bool:
elapsed += 0.5

current_state = self._hass.states.get(self._entity_id)
speaker_state = current_state.state if current_state else "unknown"

# Hard failure: speaker is idle/unavailable/off — song won't play
if speaker_state in ("idle", "unavailable", "off", "unknown"):
_LOGGER.error(
"MA playback failed after %.1fs for %s (state: %s)",
PLAYBACK_TIMEOUT, uri, speaker_state,
)
return False

_LOGGER.warning(
"MA playback not confirmed after %.1fs for %s (state: %s). "
"Returning failure so the round can retry or skip. (#418)",
PLAYBACK_TIMEOUT,
uri,
current_state.state if current_state else "unknown",
"Continuing anyway — MA may still be buffering. (#345)",
PLAYBACK_TIMEOUT, uri, speaker_state,
)
return False
# Return True: don't skip the song — MA+YTMusic can take >8s to buffer.
# Returning False would trigger retries that cause race conditions (#345).
return True

async def _play_via_sonos(self, song: dict[str, Any]) -> bool:
"""Play via Sonos (URI-based)."""
Expand Down
13 changes: 12 additions & 1 deletion custom_components/beatify/www/js/player-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
showReactionBar, hideReactionBar, setupReactionBar,
showFloatingReaction,
updateControlBarState, handleSongStopped, handleVolumeChanged,
handleNextRound, setupAdminControlBar, setupRevealControls,
handleNextRound, resetNextRoundPending, setupAdminControlBar, setupRevealControls,
setupRevealLeaderboardToggle, setupRoundAnalyticsToggle,
resetSongStoppedState,
showIntroSplashModal, hideIntroSplashModal
Expand Down Expand Up @@ -294,6 +294,11 @@ function connectWithSession() {
var sessionCookie = getSessionCookie();
if (!sessionCookie) return;

// Guard: don't open a second WebSocket if one is already connecting/open
if (state.ws && (state.ws.readyState === WebSocket.CONNECTING || state.ws.readyState === WebSocket.OPEN)) {
return;
}

var wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = wsProtocol + '//' + window.location.host + '/beatify/ws';

Expand Down Expand Up @@ -354,6 +359,11 @@ function connectWebSocket(name) {
state.playerName = name;
storePlayerName(name);

// Guard: don't open a second WebSocket if one is already connecting/open
if (state.ws && (state.ws.readyState === WebSocket.CONNECTING || state.ws.readyState === WebSocket.OPEN)) {
return;
}

var wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = wsProtocol + '//' + window.location.host + '/beatify/ws';

Expand Down Expand Up @@ -478,6 +488,7 @@ function handleServerMessage(data) {
state.currentRoundNumber = newRound;
resetSubmissionState();
}
resetNextRoundPending();
setEnergyLevel('party');
showView('game-view');
closeInviteModal();
Expand Down
30 changes: 25 additions & 5 deletions custom_components/beatify/www/js/player-game.js
Original file line number Diff line number Diff line change
Expand Up @@ -1699,11 +1699,31 @@ export function handleNextRound() {
action: 'next_round'
}));

setTimeout(function() {
nextRoundPending = false;
if (revealBtn) revealBtn.disabled = false;
if (barBtn) barBtn.disabled = false;
}, NEXT_ROUND_DEBOUNCE_MS);
// No setTimeout here — button stays disabled until the server
// sends a state update (phase change to PLAYING). This prevents
// the button from re-enabling before the new song is ready.
}
}

/**
* Reset next-round pending state. Called when a new game state arrives
* (phase change), so the button can be used again in the next reveal.
* Note: updateRevealView() in player-reveal.js already re-enables the
* button and resets its text on each REVEAL phase — this is a defensive
* measure to ensure consistent state even if the call order changes.
*/
export function resetNextRoundPending() {
nextRoundPending = false;
var revealBtn = document.getElementById('next-round-btn');
var barBtn = document.getElementById('next-round-admin-btn');
if (revealBtn) {
revealBtn.disabled = false;
revealBtn.textContent = utils.t('admin.nextRound');
}
if (barBtn) {
barBtn.disabled = false;
var labelEl = barBtn.querySelector('.control-label');
if (labelEl) labelEl.textContent = utils.t('admin.nextRound');
}
}

Expand Down
6 changes: 3 additions & 3 deletions custom_components/beatify/www/js/player.bundle.min.js

Large diffs are not rendered by default.

88 changes: 84 additions & 4 deletions tests/unit/test_media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def always_queued(*args):
):
result = await svc.play_song(_make_song(title="New Song"))

assert result is False # #418: returns False on timeout so round can retry
assert result is True # #345: return True on timeout — MA may still be buffering
assert poll_count >= 4 # but waited until timeout

@pytest.mark.asyncio
Expand Down Expand Up @@ -240,8 +240,8 @@ def realistic_flow(*args):
assert poll_count >= 8 # waited for the full realistic flow

@pytest.mark.asyncio
async def test_ma_returns_false_on_timeout(self):
"""Should return False if playback never confirmed (#418: allow retry/skip)."""
async def test_ma_returns_true_even_on_timeout(self):
"""Should return True even if playback never confirmed — MA may still be buffering (#345)."""
hass = _make_hass("buffering", media_title="Old Song")
svc = MediaPlayerService(hass, "media_player.test", platform="music_assistant")

Expand All @@ -254,7 +254,7 @@ async def test_ma_returns_false_on_timeout(self):
):
result = await svc.play_song(_make_song(title="New Song"))

assert result is False
assert result is True

@pytest.mark.asyncio
async def test_ma_first_song_no_previous_title(self):
Expand Down Expand Up @@ -307,6 +307,86 @@ async def test_sonos_still_uses_blocking_true(self):
)


@pytest.mark.asyncio
async def test_ma_ignores_wrong_song_from_previous_request(self):
"""If a previous slow song arrives, it must NOT be accepted as confirmation.

Regression test for race condition: retry fires Song 2 but Song 1
(from a previous timed-out request) starts playing first. The polling
must check that the EXPECTED title is playing, not just "any change".
"""
hass = _make_hass("idle", media_title="")
svc = MediaPlayerService(hass, "media_player.test", platform="music_assistant")

poll_count = 0
idle_state = _make_state(
"idle",
media_title="",
media_position=0,
media_position_updated_at="2020-01-01T00:00:00+00:00",
)
# Wrong song arrives (from a previous timed-out request)
wrong_song = _make_state(
"playing",
media_title="What Is Love",
media_position=1,
media_position_updated_at="2020-01-01T00:00:05+00:00",
)
# Correct song finally arrives
correct_song = _make_state(
"playing",
media_title="Ready or Not",
media_position=1,
media_position_updated_at="2020-01-01T00:00:12+00:00",
)

def race_condition_flow(*args):
nonlocal poll_count
poll_count += 1
if poll_count <= 1:
return idle_state # before
if poll_count <= 5:
return wrong_song # WRONG song playing — must NOT confirm
return correct_song # correct song arrives

hass.states.get = MagicMock(side_effect=race_condition_flow)

with patch(
"custom_components.beatify.services.media_player.asyncio.sleep",
new_callable=AsyncMock,
):
result = await svc.play_song(_make_song(title="Ready or Not"))

assert result is True
assert poll_count >= 6 # Must have waited past the wrong song

@pytest.mark.asyncio
async def test_ma_matches_title_with_suffix(self):
"""MA may append suffixes like '(Official HD Video)' — match by substring."""
hass = _make_hass("idle", media_title="")
svc = MediaPlayerService(hass, "media_player.test", platform="music_assistant")

idle_state = _make_state(
"idle", media_title="", media_position=0,
media_position_updated_at="2020-01-01T00:00:00+00:00",
)
playing_with_suffix = _make_state(
"playing",
media_title="Ready Or Not (Official HD Video)",
media_position=1,
media_position_updated_at="2020-01-01T00:00:05+00:00",
)
hass.states.get = MagicMock(side_effect=[idle_state, playing_with_suffix])

with patch(
"custom_components.beatify.services.media_player.asyncio.sleep",
new_callable=AsyncMock,
):
result = await svc.play_song(_make_song(title="Ready or Not"))

assert result is True


class TestAvailabilityCheck:
"""Tests for is_available() used in state.py pre-flight."""

Expand Down
Loading