diff --git a/package-lock.json b/package-lock.json index 9a8e6da179..9e3a8f20a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "synclounge", - "version": "6.4.16", + "version": "6.4.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "synclounge", - "version": "6.4.16", + "version": "6.4.17", "license": "MIT", "dependencies": { "cors": "^2.8.5", diff --git a/package.json b/package.json index 89a817f94b..a767a969e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "synclounge", - "version": "6.4.16", + "version": "6.4.17", "description": "Synced playback of Plex content", "author": "Chris Alves", "bin": "./server.js", diff --git a/src/__tests__/sync-seek-harness.test.js b/src/__tests__/sync-seek-harness.test.js index 4760dc7eac..72c19959b1 100644 --- a/src/__tests__/sync-seek-harness.test.js +++ b/src/__tests__/sync-seek-harness.test.js @@ -5,6 +5,8 @@ import { import { CAF } from 'caf'; import actions from '@/store/modules/slplayer/actions'; import plexActions from '@/store/modules/plexclients/actions'; +import plexGetters from '@/store/modules/plexclients/getters'; +import partyActions from '@/store/modules/synclounge/actions'; import { recordSeekIntent } from '@/player/seekIntent'; import isSeekLandingValid from '@/utils/seeklanding'; import incident from '../../test/fixtures/seek-landing-incidents.json'; @@ -339,3 +341,74 @@ describe('seek validation fuzz', () => { expect(context.commit).not.toHaveBeenCalled(); }); }); + +it('retries a selected-but-unloaded guest stream after startup cancellation', async () => { + const { state } = setup(); + const media = { machineIdentifier: 'own-server', ratingKey: 'fixture-movie' }; + state.masked = true; + state.offset = 231660; + const controller = new AbortController(); + const normalDispatch = context.dispatch.getMockImplementation(); + let decisionReached = false; + context.dispatch.mockImplementation((type, payload) => { + if (type === 'SEND_PLEX_DECISION_REQUEST') { + decisionReached = true; + controller.abort(); + return Promise.resolve(); + } + return normalDispatch(type, payload); + }); + await expect(actions.CHANGE_PLAYER_SRC(context, { signal: controller.signal })) + .rejects.toMatchObject({ name: 'AbortError' }); + expect(decisionReached).toBe(true); + expect(decoder.loads).toBe(0); + expect(state.changing).toBe(false); + expect(state.masked).toBe(true); + + Object.assign(context.getters, { + GET_HOST_ID: 'host', + GET_ROOM: 'private-fixture', + GET_HOST_USER: { media, state: 'playing' }, + }); + Object.assign(context.rootGetters, { + 'settings/GET_AUTOPLAY': true, + 'plexclients/IS_THIS_MEDIA_PLAYING': (candidate) => plexGetters.IS_THIS_MEDIA_PLAYING( + {}, + { GET_ACTIVE_MEDIA_METADATA: media }, + {}, + { 'slplayer/GET_MASK_PLAYER_STATE': state.masked }, + )(candidate), + }); + context.dispatch.mockImplementation((type, payload) => { + if (type === 'plexclients/FETCH_TIMELINE_POLL_DATA_CACHE') { + return Promise.resolve({ state: 'buffering', time: state.offset }); + } + if (type === 'plexservers/FIND_BEST_MEDIA_MATCH') return Promise.resolve(media); + if (type === 'PLAY_MEDIA_AND_SYNC_TIME') return actions.CHANGE_PLAYER_SRC(context); + return normalDispatch(type, payload); + }); + await partyActions._SYNC_MEDIA_AND_PLAYER_STATE(context, new AbortController().signal); + expect(decoder.loads).toBe(1); + expect(state.masked).toBe(false); + // Once successfully loaded, an unchanged media update must not reload it again. + await partyActions._SYNC_MEDIA_AND_PLAYER_STATE(context, new AbortController().signal); + expect(decoder.loads).toBe(1); +}); + +it('uses one bounded media retry on ordinary host updates after an interrupted guest startup', async () => { + const { state } = setup(); + const media = { machineIdentifier: 'fixture-server', ratingKey: 'interrupted-startup' }; + Object.assign(context.getters, { GET_HOST_ID: 'fixture-host', GET_ROOM: 'startup-room', GET_HOST_USER: { media } }); + Object.assign(context.rootGetters, { + 'slplayer/GET_MASK_PLAYER_STATE': true, + 'slplayer/IS_CHANGING_SOURCE': true, + 'settings/GET_AUTOPLAY': true, + }); + await partyActions.SYNC_PLAYER_STATE(context); + expect(context.dispatch).not.toHaveBeenCalledWith('SYNC_MEDIA_AND_PLAYER_STATE'); + context.rootGetters['slplayer/IS_CHANGING_SOURCE'] = false; + await partyActions.SYNC_PLAYER_STATE(context); + await partyActions.SYNC_PLAYER_STATE(context); + expect(context.dispatch.mock.calls.filter(([type]) => type === 'SYNC_MEDIA_AND_PLAYER_STATE')).toHaveLength(1); + expect(state.offset).toBe(0); +}); diff --git a/src/store/modules/plexclients/getters.js b/src/store/modules/plexclients/getters.js index e3f3bf02da..3db4c02a84 100644 --- a/src/store/modules/plexclients/getters.js +++ b/src/store/modules/plexclients/getters.js @@ -114,7 +114,8 @@ export default { ? getters.GET_ACTIVE_PLAY_QUEUE.playQueueSelectedItemOffset > 0 : false), - IS_THIS_MEDIA_PLAYING: (state, getters) => (media) => (getters.GET_ACTIVE_MEDIA_METADATA + IS_THIS_MEDIA_PLAYING: (state, getters, rootState, rootGetters) => (media) => (getters.GET_ACTIVE_MEDIA_METADATA + && !rootGetters['slplayer/GET_MASK_PLAYER_STATE'] ? getters.GET_ACTIVE_MEDIA_METADATA.machineIdentifier === media.machineIdentifier && getters.GET_ACTIVE_MEDIA_METADATA.ratingKey === media.ratingKey : false), diff --git a/src/store/modules/slplayer/actions.js b/src/store/modules/slplayer/actions.js index 447aa05de3..a7a5d8f919 100644 --- a/src/store/modules/slplayer/actions.js +++ b/src/store/modules/slplayer/actions.js @@ -309,6 +309,7 @@ export default { let ownerCancelled = false; try { ensureOwner(); } catch { ownerCancelled = true; } if ((signal?.aborted || ownerCancelled) && revision === sourceRevision) { + dispatch('REPORT_PLAYBACK_DIAGNOSTIC', { event: 'source-startup-cancelled' }); if (loadStarted) { try { await unload(); diff --git a/src/store/modules/synclounge/actions.js b/src/store/modules/synclounge/actions.js index 43d5804ca6..d891a1d776 100644 --- a/src/store/modules/synclounge/actions.js +++ b/src/store/modules/synclounge/actions.js @@ -22,6 +22,7 @@ const getSocketEventTimeout = (rootGetters) => { }; let manualSyncRevision = 0; +let retriedGuestStartup = null; // Hold the requested party-pause state until the host confirms the matching command. let pendingPartyPause = null; @@ -993,12 +994,31 @@ export default { await dispatch('_SYNC_PLAYER_STATE', cancelSignal); }, - SYNC_PLAYER_STATE: async ({ dispatch, getters, commit }) => { + SYNC_PLAYER_STATE: async ({ + dispatch, getters, commit, rootGetters, + }) => { if (getters.AM_I_HOST || getters.GET_SYNC_CANCEL_TOKEN || getters.IS_HOST_GRACE_PERIOD || getters.GET_HOST_RESTORE_PENDING_ID) { return; } + // A cancelled startup can leave selected metadata without a loaded stream. + // Retry media selection once, rather than polling/seeking an empty decoder forever. + if (rootGetters?.['slplayer/GET_MASK_PLAYER_STATE']) { + if (rootGetters['slplayer/IS_CHANGING_SOURCE']) return; + const media = getters.GET_HOST_USER?.media; + if (!media || !rootGetters['settings/GET_AUTOPLAY']) return; + const key = JSON.stringify([ + getters.GET_ROOM, getters.GET_HOST_ID, media.machineIdentifier, media.ratingKey, + ]); + if (retriedGuestStartup === key) return; + retriedGuestStartup = key; + dispatch('slplayer/REPORT_PLAYBACK_DIAGNOSTIC', { event: 'guest-startup-retry' }, { root: true }); + await dispatch('SYNC_MEDIA_AND_PLAYER_STATE'); + return; + } + retriedGuestStartup = null; + // eslint-disable-next-line new-cap const token = new CAF.cancelToken(); commit('SET_SYNC_CANCEL_TOKEN', token);