From dc7eb86ce693e8bd11f32eb04aefc3a583944e53 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:08:39 +0200 Subject: [PATCH] fix(sync): harden episode transition compatibility --- docs/CHANGELOG.md | 13 + docs/PROTOCOL.md | 73 ++- extension/background.js | 270 ++++++++- extension/content.js | 533 ++++++++++++++++-- extension/episode-sync-v2.test.mjs | 113 +++- extension/episode-utils.js | 74 ++- extension/episode-utils.test.mjs | 93 ++- scripts/build-extension.cjs | 1 + scripts/test-server-ws.mjs | 243 ++++++-- server/index.js | 171 ++++-- shared/README.md | 6 +- shared/constants.js | 4 + .../episode-transition-quarantine.spec.mjs | 159 ++++++ tests/e2e/extension.spec.mjs | 92 +++ 14 files changed, 1625 insertions(+), 220 deletions(-) create mode 100644 tests/e2e/episode-transition-quarantine.spec.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c54438c..fd12ffd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v3.2.0] — 2026-09-01 + +This release makes episode transitions relay-authoritative and safe across mixed +extension and relay versions. + +### Changed +- **Reliable next-episode sync** — Quarantines automatic source-swap playback noise, waits up to 120 seconds for every participant to load, and commits playback only after every prepared player confirms execution. +- **Rolling compatibility** — Preserves the released legacy lobby contract for older extensions and relays while keeping new transactions correlated, bounded, and reconnect-safe. + +### Fixed +- **Episode transition races** — Handles title/load event reordering, long metadata titles, clock skew, disconnects, execute failures, and deliberate user controls without freezing or prematurely pausing peers. +- **Room-exit target cleanup** — Clears stale selected tabs after manual leaves, inactivity removal, and room closure so reinjection works on the next selection. + ## [v3.1.5] — 2026-08-25 This patch fixes room-exit cleanup and excessive popup width. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 69dbc60..b78f105 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -364,6 +364,9 @@ Otherwise, even a delayed execute is relayed to release paused receivers. Episode lobby coordination is implemented primarily in the extension. The relay tracks enough state to include `activeLobby` in `room_data` for later joiners. +For compatibility with released extensions, ordinary legacy `play`, `pause`, and +`seek` events do not cancel an active lobby. Only the existing lobby/Force Sync +choreography or an explicit `episode_lobby_cancel` ends it. ### `episode_lobby` @@ -535,36 +538,58 @@ Clients should treat a missing or unknown capabilities list as unsupported. ## Episode Sync v2 Relays advertise `"episode-sync-v2"`. Updated clients use the additive -`episode_sync_v2` event without changing `PROTOCOL_VERSION`; automatic episode -sync is skipped safely when the relay omits the capability or any current room -peer did not announce it. Manual Force Sync remains on the legacy event family. +`episode_sync_v2` event without changing `PROTOCOL_VERSION`. When the relay +omits the capability, or rejects START because a current peer is legacy, updated +clients fall back to the released legacy lobby wire. Manual Force Sync remains +on the legacy event family. The relay creates the transaction ID, freezes non-desynced participants, owns deadlines, and is the only component that advances: ```text -start -> lobby/loading -> prepare -> execute - \-> cancel +start -> lobby/loading -> prepare -> execute/executing -> complete + | | | + +------------------+--------------+-> cancel ``` -Client phases are `start`, `loaded`, `prepared`, `failed`, and `cancel`. Relay -phases are `lobby`, `prepare`, `execute`, and `cancel`. Every post-start frame is -correlated by `transactionId`; duplicate or stale frames are idempotently -ignored. No participant is pre-marked loaded. `execute` is emitted exactly once -only after every frozen participant reports `loaded`, then pauses, seeks to -0:00, reaches `readyState >= 3`, and remains on the same player/title in paused, -non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`. +`start` carries bounded `expectedTitle` and may carry a sanitized +`expectedEpisodeId` (`S01E06` or `EP006`). Relay state includes `remainingMs`, +not an absolute relay wall-clock deadline. Client phases are `start`, `loaded`, +`prepared`, `executed`, `failed`, `failed_execute`, and `cancel`. Relay phases +are `lobby`, `prepare`, `execute`, `complete`, and `cancel`. Every post-start +frame is correlated by `transactionId`; duplicate or stale frames are +idempotently ignored. -Any timeout, failed player action, participant departure, manual media command, -room/target change, or explicit cancellation produces `cancel`; v2 never -executes after a timeout. Peers resume only when that transaction paused a -previously playing player and no newer local or room action superseded it. A -superseding room command defines the next state without racing a restoration. -Peers joining after `start` are excluded from the frozen barrier and receive no -v2 frames for that transaction. Clients revalidate the complete prepared state -again immediately before applying `execute`. +No participant is pre-marked loaded. `execute` is emitted exactly once only +after every frozen participant reports `loaded`, then pauses, seeks to 0:00, +reaches `readyState >= 3`, and remains on the same player/episode in paused, +non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`. The transaction remains +in the relay's internal `executing` phase until every participant reports +`executed`. Only then does the relay commit canonical `playing` at 0:00 and emit +terminal `complete`. -Legacy episode events remain accepted. A new relay binds their PREPARE, EXECUTE, -and CANCEL to the accepted lobby initiator, limiting duplicate old-client wire -actions; an unmodified old non-initiator can still run its own local timeout, so -full v2 guarantees require capable extensions on every peer. +Raw `play`, `pause`, and `seek` churn is tolerated during `lobby/loading`, where +players commonly replace sources. A current client can classify a fresh local +gesture as `episodeSyncIntent: "manual"`; that command supersedes loading before +the sanitized media command is relayed. During `prepare`, every such media +command supersedes the barrier and releases prepared peers. Explicit +`failed`/`cancel`, Force Sync, +participant desync/departure, any successful join or reconnect, and every phase +timeout cancel the transaction. A join always cancels rather than extending the +frozen participant set or reusing ACKs from an earlier socket. v2 never advances +to a later phase after a timeout. + +If `failed_execute` or the execute-ACK deadline ends `executing`, the relay emits +`cancel` with `settlePlaybackState: "paused"` and `targetTime: 0`, and commits +canonical paused state. Clients retain transaction state until `complete` or +`cancel`, allowing that terminal settlement to reach peers that already started +playback. + +The independent deadlines are 120 seconds for v2 loading, 15 seconds for +prepare, and 10 seconds for execute acknowledgements. The released legacy lobby +keeps its 60-second client-side deadline for old/new compatibility. + +Legacy episode events remain accepted with their released origin/main wire +semantics. The relay does not globally bind legacy PREPARE, EXECUTE, or CANCEL +to one lobby owner because old clients provide no field that distinguishes an +automatic duplicate from an intentional manual Force Sync. diff --git a/extension/background.js b/extension/background.js index 7f3b075..22e33db 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,7 +1,7 @@ -import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js'; +import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, EPISODE_SYNC_V2_LOAD_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT, EPISODE_SYNC_V2_EXECUTE_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js'; import { generateUsername } from './shared/names.js'; import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; -import { sameEpisode, sameEpisodeStrict, extractEpisodeId } from './episode-utils.js'; +import { createEpisodeWireIdentity, createLocalEpisodeDeadline, extractEpisodeId, isEpisodeSyncV2StartContextCurrent, matchesEpisodeSyncV2StartRejection, sameEpisode, sameEpisodeIdentity, toEpisodeWireTitle } from './episode-utils.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { initTabManager } from './modules/tab-manager.js'; import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; @@ -677,6 +677,8 @@ let forceSyncTimeout = null; let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt } let episodeLobbyTimeout = null; let episodeSyncV2 = null; +let episodeSyncV2PendingStart = null; +let episodeSyncV2PendingStartId = 0; // --- Storage Utils --- @@ -818,7 +820,9 @@ function emitEpisodeLobbyForCurrentPrivacy() { if (episodeLobby !== lobby || currentRoom?.roomId !== roomId || settings.roomId !== roomId) return; - const expectedTitle = sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode); + const expectedTitle = toEpisodeWireTitle( + sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode) + ); if (expectedTitle) { emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle }); } @@ -1217,10 +1221,13 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) { const transactionId = typeof value.transactionId === 'string' ? value.transactionId.substring(0, 64) : ''; - const phase = value.phase === 'lobby' || value.phase === 'prepare' ? value.phase : ''; - const expectedTitle = typeof value.expectedTitle === 'string' - ? value.expectedTitle.substring(0, 100) - : ''; + const phase = value.phase === 'lobby' || value.phase === 'prepare' + ? value.phase + : (value.phase === 'execute' || value.phase === 'executing' ? 'execute' : ''); + const expectedTitle = toEpisodeWireTitle(value.expectedTitle) || ''; + const expectedEpisodeId = typeof value.expectedEpisodeId === 'string' + ? value.expectedEpisodeId.substring(0, 16) + : extractEpisodeId(expectedTitle); const initiatorPeerId = typeof value.initiatorPeerId === 'string' ? value.initiatorPeerId.substring(0, 16) : ''; @@ -1238,20 +1245,117 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) { if (participants.length < 2 || !participantSet.has(initiatorPeerId) || (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null; + const phaseTimeout = phase === 'lobby' + ? EPISODE_SYNC_V2_LOAD_TIMEOUT + : (phase === 'prepare' ? EPISODE_SYNC_V2_PREPARE_TIMEOUT : EPISODE_SYNC_V2_EXECUTE_TIMEOUT); + // The relay's wall clock is not comparable to the browser's. Convert its + // bounded remaining duration into a local deadline. Older v2 relays omit + // remainingMs; a full local phase timeout is safer than trusting clock skew. + const localDeadline = createLocalEpisodeDeadline(value.remainingMs, phaseTimeout); return { transactionId, phase, expectedTitle, + expectedEpisodeId, initiatorPeerId, participants, loadedPeers, preparedPeers, createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(), - deadlineAt: Number.isFinite(value.deadlineAt) ? value.deadlineAt : null, - revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1 + deadlineAt: localDeadline.deadlineAt, + remainingMs: localDeadline.remainingMs, + revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1, + executionReportStatus: value.executionReportStatus === 'executed' + || value.executionReportStatus === 'failed_execute' + ? value.executionReportStatus + : null, + executionReportInFlight: false }; } +function createPendingEpisodeSyncV2Start(identity, sender = null) { + return { + requestId: ++episodeSyncV2PendingStartId, + roomId: currentRoom?.roomId || null, + connectionGeneration, + targetGeneration: targetActivationGeneration, + tabId: normalizeTabId(sender?.tab?.id ?? currentTabId), + frameId: normalizeFrameId(sender?.frameId ?? currentTargetFrameId), + documentId: sender?.documentId || currentTargetDocumentId || null, + expectedTitle: identity.expectedTitle, + expectedEpisodeId: identity.expectedEpisodeId, + requestedAt: Date.now() + }; +} + +function isCurrentEpisodeSyncV2Start(pending) { + return episodeSyncV2PendingStart === pending + && isEpisodeSyncV2StartContextCurrent(pending, { + roomId: currentRoom?.roomId || null, + connectionGeneration, + targetGeneration: targetActivationGeneration, + tabId: normalizeTabId(currentTabId) + }); +} + +function startLegacyEpisodeLobbyForTransition(identity, pending = null) { + if (!identity?.expectedTitle || !identity.expectedEpisodeId) return 'invalid_identity'; + if (pending && !isCurrentEpisodeSyncV2Start(pending)) return 'stale_session'; + // Legacy has no separate expectedEpisodeId field. If the 100-code-unit + // title clamp removed the episode marker, use the canonical ID itself so + // old content scripts can still match the local full title. + const lobbyTitle = extractEpisodeId(identity.expectedTitle) + ? identity.expectedTitle + : identity.expectedEpisodeId; + + if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, lobbyTitle)) { + if (!episodeLobby.readyPeers.includes(peerId)) { + if (!emitLive(EVENTS.EPISODE_READY, { + peerId, + title: lobbyTitle, + expectedTitle: episodeLobby.expectedTitle + })) return 'offline'; + episodeLobby.readyPeers.push(peerId); + persistEpisodeLobby(); + broadcastLobbyUpdate(); + checkEpisodeLobbyCompletion(); + } + return 'ready_sent'; + } + + if (episodeLobby) clearEpisodeLobbyState(); + if (!emitLive(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle })) return 'offline'; + + supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE); + episodeLobby = { + expectedTitle: lobbyTitle, + initiatorPeerId: peerId, + readyPeers: [peerId], + createdAt: Date.now() + }; + persistEpisodeLobby(); + broadcastLobbyUpdate(); + addLog(`Episode lobby created via compatibility fallback: "${lobbyTitle}"`, 'info'); + + const targetTabId = pending?.tabId ?? normalizeTabId(currentTabId); + if (targetTabId !== null) { + sendMessageToFrame( + targetTabId, + pending?.frameId ?? currentTargetFrameId, + { type: 'PAUSE_FOR_LOBBY', expectedTitle: lobbyTitle }, + null, + pending?.documentId ?? currentTargetDocumentId + ).catch(() => {}); + } + + episodeLobbyTimeout = setTimeout( + () => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), + EPISODE_LOBBY_TIMEOUT + ); + checkEpisodeLobbyCompletion(); + return 'lobby_created'; +} + function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) { return clearTargetSelectionForLifecycle({ expectedTabId, @@ -1266,6 +1370,7 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left reconnectFailed = false; reconnectAttempts = 0; reconnectStartTime = null; + episodeSyncV2PendingStart = null; completeForceSyncBeforeTargetChange(null); if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId }); @@ -2272,7 +2377,13 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2; persistEpisodeSyncV2(); broadcastLobbyUpdate(); - if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {}); + if (shouldNotifyContent) { + if (authoritativeEpisodeSyncV2.phase === 'execute') { + executeEpisodeSyncV2FromRelay(authoritativeEpisodeSyncV2).catch(() => {}); + } else { + sendEpisodeSyncV2ToContent().catch(() => {}); + } + } } else if (episodeSyncV2) { clearEpisodeSyncV2State({ reason: 'relay_state_ended' }); } else { @@ -2701,6 +2812,28 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } const phase = data.phase; if (phase === 'cancel' && !data.transactionId) { + const pending = episodeSyncV2PendingStart; + const matchesPending = episodeSyncV2PendingStart === pending + && matchesEpisodeSyncV2StartRejection(pending, { + roomId: currentRoom?.roomId || null, + connectionGeneration, + targetGeneration: targetActivationGeneration, + tabId: normalizeTabId(currentTabId) + }, data); + if (!matchesPending) { + addLog(`Ignored stale Episode Sync v2 rejection: ${data.reason || 'rejected'}`, 'info'); + break; + } + if (data.reason === 'capability_mismatch') { + const fallbackStatus = startLegacyEpisodeLobbyForTransition({ + expectedTitle: pending.expectedTitle, + expectedEpisodeId: pending.expectedEpisodeId + }, pending); + episodeSyncV2PendingStart = null; + addLog(`Episode Sync v2 unavailable; compatibility fallback: ${fallbackStatus}`, 'warn'); + break; + } + episodeSyncV2PendingStart = null; addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn'); break; } @@ -2728,6 +2861,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con clearEpisodeSyncV2State({ reason: 'transaction_replaced' }); } if (episodeLobby) clearEpisodeLobbyState(); + if (episodeSyncV2PendingStart + && incoming.expectedTitle === episodeSyncV2PendingStart.expectedTitle + && incoming.expectedEpisodeId === episodeSyncV2PendingStart.expectedEpisodeId) { + episodeSyncV2PendingStart = null; + } episodeSyncV2 = incoming; if (currentRoom) currentRoom.episodeSyncV2 = incoming; persistEpisodeSyncV2(); @@ -2736,14 +2874,16 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info'); break; } - if ((phase === 'execute' || phase === 'cancel') + if ((phase === 'execute' || phase === 'complete' || phase === 'cancel') && episodeSyncV2 && data.transactionId === episodeSyncV2.transactionId) { const completed = episodeSyncV2; if (phase === 'execute') { + await executeEpisodeSyncV2FromRelay(data); + } else if (phase === 'complete') { sendMessageToCurrentContent({ type: 'EPISODE_SYNC_V2', - transaction: { ...completed, phase: 'execute', targetTime: 0 } + transaction: { ...completed, phase: 'complete', targetTime: 0 } }).catch(() => {}); if (currentRoom && Array.isArray(currentRoom.peers)) { currentRoom.peers.forEach(candidate => { @@ -2758,7 +2898,10 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' }); addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success'); } else { - clearEpisodeSyncV2State({ reason: data.reason || 'cancelled' }); + clearEpisodeSyncV2State({ + reason: data.reason || 'cancelled', + relayState: data + }); addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn'); } } @@ -2928,6 +3071,7 @@ function completeForceSyncBeforeTargetChange(nextTabId) { const normalizedNextTabId = normalizeTabId(nextTabId); if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return; + episodeSyncV2PendingStart = null; if (episodeSyncV2) cancelEpisodeSyncV2('target_changed'); if (!isForceSyncInitiator) return; @@ -2945,7 +3089,7 @@ function episodeLobbyForUi() { return { expectedTitle: episodeSyncV2.expectedTitle, initiatorPeerId: episodeSyncV2.initiatorPeerId, - readyPeers: episodeSyncV2.phase === 'prepare' + readyPeers: episodeSyncV2.phase === 'prepare' || episodeSyncV2.phase === 'execute' ? [...episodeSyncV2.preparedPeers] : [...episodeSyncV2.loadedPeers], createdAt: episodeSyncV2.createdAt, @@ -2972,7 +3116,60 @@ function sendEpisodeSyncV2ToContent(transaction = episodeSyncV2) { }); } -function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' } = {}) { +async function executeEpisodeSyncV2FromRelay(relayState) { + const transaction = episodeSyncV2; + if (!transaction + || !relayState + || relayState.transactionId !== transaction.transactionId + || transaction.executionReportInFlight + || transaction.executionReportStatus) return false; + + transaction.phase = 'execute'; + const remainingMs = Number.isFinite(relayState.remainingMs) + ? Math.max(0, Math.min(EPISODE_SYNC_V2_EXECUTE_TIMEOUT, relayState.remainingMs)) + : EPISODE_SYNC_V2_EXECUTE_TIMEOUT; + transaction.remainingMs = remainingMs; + transaction.deadlineAt = Date.now() + remainingMs; + transaction.executionReportInFlight = true; + persistEpisodeSyncV2(); + broadcastLobbyUpdate(); + + let response = null; + try { + response = await sendMessageToCurrentContent({ + type: 'EPISODE_SYNC_V2', + transaction: { + ...transaction, + phase: 'execute', + targetTime: 0 + } + }); + } catch (error) { + addLog(`Episode Sync v2 execute content error: ${error.message}`, 'warn'); + } + + if (episodeSyncV2 !== transaction) return false; + const reportStatus = response?.status === 'executed' ? 'executed' : 'failed_execute'; + transaction.executionReportInFlight = false; + const sent = emitLive(EVENTS.EPISODE_SYNC_V2, { + phase: reportStatus, + transactionId: transaction.transactionId, + reason: reportStatus === 'failed_execute' + ? (typeof response?.reason === 'string' ? response.reason.substring(0, 32) : 'content_execute_failed') + : undefined + }); + transaction.executionReportStatus = sent ? reportStatus : null; + persistEpisodeSyncV2(); + addLog( + sent + ? `Episode Sync v2 execute result sent: ${reportStatus}` + : `Episode Sync v2 execute result could not be sent: ${reportStatus}`, + reportStatus === 'executed' && sent ? 'info' : 'warn' + ); + return sent; +} + +function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled', relayState = null } = {}) { const previous = episodeSyncV2; episodeSyncV2 = null; if (currentRoom) currentRoom.episodeSyncV2 = null; @@ -2983,6 +3180,7 @@ function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' } type: 'EPISODE_SYNC_V2', transaction: { ...previous, + ...(relayState && typeof relayState === 'object' ? relayState : {}), phase: 'cancel', reason } @@ -5623,7 +5821,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { const newTitle = message.payload && message.payload.newTitle; if (newTitle && extractEpisodeId(newTitle) === null) { - addLog(`Episode change detected ("${newTitle}") but no episode ID was found; ignoring.`, 'info'); + addLog(`Episode change detected ("${toEpisodeWireTitle(newTitle) || 'unknown'}") but no episode ID was found; ignoring.`, 'info'); sendResponse({ status: 'not_an_episode' }); return; } @@ -5637,12 +5835,14 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ignored_stale_session' }); return; } - const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode); - if (!lobbyTitle) { + const sharedLobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode); + const episodeIdentity = createEpisodeWireIdentity(sharedLobbyTitle); + if (!episodeIdentity) { addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info'); sendResponse({ status: 'title_privacy_no_lobby' }); return; } + const lobbyTitle = episodeIdentity.expectedTitle; // Check setting const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']); @@ -5680,21 +5880,29 @@ async function handleAsyncMessage(message, sender, sendResponse) { return; } - // Automatic episode sync v2 is relay-owned. Never self-pause or fall - // back to the legacy client-owned lobby: on an old/mixed relay that path - // can create multiple Force Sync initiators. Manual Force Sync remains - // available and unchanged. + // Prefer the relay-owned barrier. Old relays and mixed rooms retain the + // established legacy lobby, but only from this exact room/target/start + // context so a delayed rejection cannot resurrect stale automation. if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) { - addLog(`Episode change ("${lobbyTitle}") — relay lacks Episode Sync v2; automatic sync skipped safely.`, 'warn'); - sendResponse({ status: 'episode_sync_v2_unsupported' }); + const pending = createPendingEpisodeSyncV2Start(episodeIdentity, sender); + episodeSyncV2PendingStart = pending; + const fallbackStatus = startLegacyEpisodeLobbyForTransition(episodeIdentity, pending); + episodeSyncV2PendingStart = null; + addLog(`Episode change ("${lobbyTitle}") — legacy relay fallback: ${fallbackStatus}.`, 'warn'); + sendResponse({ status: fallbackStatus }); return; } - if (episodeSyncV2 && sameEpisode(episodeSyncV2.expectedTitle, lobbyTitle)) { + if (episodeSyncV2 + && episodeSyncV2.expectedTitle === episodeIdentity.expectedTitle + && episodeSyncV2.expectedEpisodeId === episodeIdentity.expectedEpisodeId) { sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId }); return; } if (episodeSyncV2) cancelEpisodeSyncV2('new_episode'); - if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', expectedTitle: lobbyTitle })) { + const pending = createPendingEpisodeSyncV2Start(episodeIdentity, sender); + episodeSyncV2PendingStart = pending; + if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', ...episodeIdentity })) { + if (episodeSyncV2PendingStart === pending) episodeSyncV2PendingStart = null; addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn'); sendResponse({ status: 'episode_sync_v2_offline' }); return; @@ -5725,7 +5933,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ok' }); return; } - const readyTitle = sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode); + const readyTitle = toEpisodeWireTitle( + sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode) + ); lobby.readyPeers.push(peerId); persistEpisodeLobby(); broadcastLobbyUpdate(); @@ -5758,7 +5968,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { } const localTitle = message.payload?.title; if (localPhase !== 'failed' - && (typeof localTitle !== 'string' || !sameEpisodeStrict(localTitle, transaction.expectedTitle))) { + && (typeof localTitle !== 'string' || !sameEpisodeIdentity( + localTitle, + transaction.expectedTitle, + transaction.expectedEpisodeId + ))) { sendResponse({ status: 'ignored_episode_mismatch' }); return; } diff --git a/extension/content.js b/extension/content.js index 9acff60..547a518 100644 --- a/extension/content.js +++ b/extension/content.js @@ -331,8 +331,17 @@ let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush // --- Episode Auto-Sync State --- + const EPISODE_TRANSITION_QUARANTINE_MS = 2000; + const EPISODE_TRANSITION_CANDIDATE_MS = 30000; + const EPISODE_TRANSITION_POLL_MS = 250; + const EPISODE_TRANSITION_END_WINDOW_SECONDS = 30; let lastKnownMediaTitle = null; let episodeTransitionDebounce = null; + let episodeTransitionCandidate = null; + let episodeTransitionPollTimer = null; + let episodeTransitionQuarantine = null; + let episodeTransitionNoiseVideo = null; + let episodeTransitionNoiseUntil = 0; let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby) let lobbyPollTimer = null; let episodeSyncV2State = null; @@ -354,6 +363,10 @@ if (destroyed) return; if (area === 'local' && changes.autoSyncNextEpisode) { _autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false; + if (!_autoSyncEnabled) { + clearEpisodeTransitionCandidate({ commitCurrentTitle: true }); + flushEpisodeTransitionQuarantine(); + } } if (area === 'local' && changes.audioSettings) { _audioSettings = mergeAudioSettings(changes.audioSettings.newValue); @@ -1090,6 +1103,13 @@ // Returns null if no episode pattern found. // --- SHARED_EPISODE_UTILS_INJECT_START --- // This block is automatically replaced by /scripts/build-extension.cjs + const EPISODE_WIRE_TITLE_LENGTH = 100; + + function toEpisodeWireTitle(title) { + if (typeof title !== 'string' || title.length === 0) return null; + return title.substring(0, EPISODE_WIRE_TITLE_LENGTH); + } + function extractEpisodeId(title) { if (!title || typeof title !== 'string') return null; const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); @@ -1125,10 +1145,24 @@ // it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced // S/E-only titles still fall back to the canonical episode ID. function sameEpisodeStrict(titleA, titleB) { - if (!sameEpisode(titleA, titleB)) return false; - const contextA = episodeContext(titleA); - const contextB = episodeContext(titleB); - return !contextA || !contextB || contextA === contextB; + const wireTitleA = toEpisodeWireTitle(titleA); + const wireTitleB = toEpisodeWireTitle(titleB); + if (!sameEpisode(wireTitleA, wireTitleB)) return false; + const contextA = episodeContext(wireTitleA); + const contextB = episodeContext(wireTitleB); + if (!contextA || !contextB || contextA === contextB) return true; + const [shorter, longer] = contextA.length <= contextB.length + ? [contextA, contextB] + : [contextB, contextA]; + return shorter.length >= 4 && ` ${longer} `.includes(` ${shorter} `); + } + + function sameEpisodeIdentity(localTitle, expectedTitle, expectedEpisodeId = null) { + const normalizedExpectedId = typeof expectedEpisodeId === 'string' + ? expectedEpisodeId.trim().toUpperCase().substring(0, 16) + : null; + if (normalizedExpectedId && extractEpisodeId(localTitle) !== normalizedExpectedId) return false; + return sameEpisodeStrict(localTitle, expectedTitle); } // --- SHARED_EPISODE_UTILS_INJECT_END --- @@ -1142,26 +1176,177 @@ if (!idA || !idB) return false; // At least one unparseable → allow return idA !== idB; // Both parseable → only block if different } - function checkEpisodeTransition() { + function getEpisodeSource(video) { + return video ? (video.currentSrc || video.src || null) : null; + } + function isNearEpisodeBoundary(video, current = getSyncCurrentTime(video)) { + if (!video || current === null) return false; + if (video.ended) return true; + const duration = getSyncDuration(video); + const boundaryWindow = Math.min( + EPISODE_TRANSITION_END_WINDOW_SECONDS, + Math.max(3, duration * 0.05) + ); + return duration > 0 && current >= Math.max(0, duration - boundaryWindow); + } + + function isAutomaticTerminalEvent(video, current = getSyncCurrentTime(video)) { + if (!video || current === null) return false; + if (video.ended) return true; + const duration = getSyncDuration(video); + return duration > 0 + && duration - current <= 1.5 + && hcmClassifyIntent() !== 'deliberate'; + } + + function clearEpisodeTransitionPoll() { + if (!episodeTransitionPollTimer) return; + clearTimeout(episodeTransitionPollTimer); + episodeTransitionPollTimer = null; + } + + function clearEpisodeTransitionCandidate({ commitCurrentTitle = false } = {}) { + clearEpisodeTransitionPoll(); + episodeTransitionCandidate = null; + if (commitCurrentTitle) { + const title = getMediaTitle(); + if (title) lastKnownMediaTitle = title; + } + } + + function scheduleEpisodeTransitionPoll() { + if (destroyed || pageSuspended || episodeTransitionPollTimer || !episodeTransitionCandidate) return; + episodeTransitionPollTimer = setTimeout(() => { + episodeTransitionPollTimer = null; + if (destroyed || pageSuspended || !episodeTransitionCandidate) return; + if (Date.now() >= episodeTransitionCandidate.deadlineAt) { + clearEpisodeTransitionCandidate({ commitCurrentTitle: true }); + flushEpisodeTransitionQuarantine(); + return; + } + checkEpisodeTransition('poll'); + scheduleEpisodeTransitionPoll(); + }, EPISODE_TRANSITION_POLL_MS); + } + + function ensureEpisodeTransitionCandidate(video, reason, { + baselineSource = lastVideoSrc, + nearBoundary = false, + transitionLike = false, + quarantineEvents = false + } = {}) { + if (!_autoSyncEnabled || !video || !lastKnownMediaTitle) return null; + const source = getEpisodeSource(video); + if (!episodeTransitionCandidate || episodeTransitionCandidate.video !== video) { + clearEpisodeTransitionCandidate(); + episodeTransitionCandidate = { + video, + baselineTitle: lastKnownMediaTitle, + baselineSource: baselineSource === undefined ? source : baselineSource, + startedAt: Date.now(), + deadlineAt: Date.now() + EPISODE_TRANSITION_CANDIDATE_MS, + nearBoundary: nearBoundary === true, + transitionLike: transitionLike === true, + quarantineEvents: quarantineEvents === true || transitionLike === true, + reason + }; + } else { + episodeTransitionCandidate.nearBoundary ||= nearBoundary === true; + episodeTransitionCandidate.transitionLike ||= transitionLike === true; + episodeTransitionCandidate.quarantineEvents ||= quarantineEvents === true || transitionLike === true; + episodeTransitionCandidate.reason = reason || episodeTransitionCandidate.reason; + } + scheduleEpisodeTransitionPoll(); + return episodeTransitionCandidate; + } + + function discardEpisodeTransitionQuarantine() { + const quarantine = episodeTransitionQuarantine; + episodeTransitionQuarantine = null; + if (quarantine?.timer) clearTimeout(quarantine.timer); + } + + function checkEpisodeTransition(reason = 'signal') { const currentTitle = getMediaTitle(); - const video = findVideo(); + if (!video) return false; - const current = video ? getSyncCurrentTime(video) : null; - // Only trigger if: we had a previous title, the title changed, - // a video exists, and we're near the start of new content. - if (lastKnownMediaTitle && currentTitle - && !sameEpisode(currentTitle, lastKnownMediaTitle) - && extractEpisodeId(currentTitle) !== null - && video - && current !== null && current < 5 - && video.readyState >= 1) { - onEpisodeTransition(currentTitle); + const current = getSyncCurrentTime(video); + const source = getEpisodeSource(video); + if (!lastKnownMediaTitle) { + if (currentTitle) lastKnownMediaTitle = currentTitle; + return false; } - // Always track the latest known title - if (currentTitle) lastKnownMediaTitle = currentTitle; + const candidate = episodeTransitionCandidate; + const baselineTitle = candidate?.video === video + ? candidate.baselineTitle + : lastKnownMediaTitle; + const baselineSource = candidate?.video === video + ? candidate.baselineSource + : lastVideoSrc; + const titleChanged = !!currentTitle && !sameEpisode(currentTitle, baselineTitle); + const sourceChanged = baselineSource !== undefined + && !!baselineSource + && !!source + && source !== baselineSource; + const nearBoundary = isNearEpisodeBoundary(video, current); + const nearNewStart = current !== null && current < 5; + const transitionSignal = titleChanged + || sourceChanged + || reason === 'source_changed' + || reason === 'loadstart' + || reason === 'emptied' + || reason === 'loadeddata' + || reason === 'player_changed' + || candidate?.nearBoundary; + + if (transitionSignal) { + const nextCandidate = ensureEpisodeTransitionCandidate(video, reason, { + baselineSource, + nearBoundary, + transitionLike: titleChanged + || (sourceChanged && (nearNewStart || candidate?.nearBoundary === true)), + quarantineEvents: titleChanged + || sourceChanged + || reason === 'player_changed' + || candidate?.nearBoundary === true + }); + if (nextCandidate && nearNewStart + && (sourceChanged || reason === 'loadeddata') + && nextCandidate.nearBoundary) { + nextCandidate.transitionLike = true; + } + } + + // Commit the title baseline only after the replacement is actually usable. + // This keeps both title-before-loadeddata and loadeddata-before-title alive + // as pending candidates instead of consuming the only transition signal. + if (titleChanged + && extractEpisodeId(currentTitle) !== null + && current !== null && current < 5 + && video.readyState >= 1) { + lastKnownMediaTitle = currentTitle; + clearEpisodeTransitionCandidate(); + discardEpisodeTransitionQuarantine(); + episodeTransitionNoiseVideo = video; + episodeTransitionNoiseUntil = Date.now() + EPISODE_TRANSITION_QUARANTINE_MS; + cancelPlayPauseCoalesce(); + if (seekDebounceTimer) { + clearTimeout(seekDebounceTimer); + seekDebounceTimer = null; + } + onEpisodeTransition(currentTitle); + return true; + } + + // Harmless metadata reformatting for the same canonical episode may update + // the baseline. A genuinely different episode stays pending until ready. + if (currentTitle && sameEpisode(currentTitle, baselineTitle)) { + lastKnownMediaTitle = currentTitle; + } + return false; } function onEpisodeTransition(newTitle) { @@ -1304,7 +1489,7 @@ && video && video === findVideo() && video.isConnected !== false - && sameEpisodeStrict(getMediaTitle(), state.expectedTitle) + && sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId) && video.paused; if (mayResume) { const resumed = await tryMediaAction(EVENTS.PLAY); @@ -1318,8 +1503,8 @@ const title = getMediaTitle(); const matches = video && title - && sameEpisodeStrict(title, state.expectedTitle) - && video.readyState >= 1 + && sameEpisodeIdentity(title, state.expectedTitle, state.expectedEpisodeId) + && video.readyState >= 3 && getSyncCurrentTime(video) !== null; if (!matches) { state.loadCandidateVideo = null; @@ -1339,8 +1524,19 @@ function startEpisodeSyncV2Lobby(transaction) { if (!transaction?.transactionId || !transaction.expectedTitle) return; + // Any held source-swap media events predate the relay-owned transaction. + // They are transition noise, not a newer intent that may cancel the lobby. + discardEpisodeTransitionQuarantine(); + cancelPlayPauseCoalesce(); + if (seekDebounceTimer) { + clearTimeout(seekDebounceTimer); + seekDebounceTimer = null; + } if (episodeSyncV2State?.transactionId === transaction.transactionId) { episodeSyncV2State.phase = 'lobby'; + episodeSyncV2State.expectedEpisodeId = typeof transaction.expectedEpisodeId === 'string' + ? transaction.expectedEpisodeId + : episodeSyncV2State.expectedEpisodeId; episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : episodeSyncV2State.deadlineAt; @@ -1351,6 +1547,9 @@ episodeSyncV2State = { transactionId: transaction.transactionId, expectedTitle: transaction.expectedTitle, + expectedEpisodeId: typeof transaction.expectedEpisodeId === 'string' + ? transaction.expectedEpisodeId + : null, phase: 'lobby', generation: episodeSyncV2Generation, loadedReported: false, @@ -1365,7 +1564,9 @@ pausedByTransaction: false, manualAction: false, programmaticPausePending: false, - prepareStarted: false + prepareStarted: false, + executePromise: null, + executeResult: null }; stopEpisodeSyncV2Poll(); checkEpisodeSyncV2Loaded(episodeSyncV2State); @@ -1384,7 +1585,7 @@ && state.phase === 'prepare' && video === findVideo() && video.isConnected !== false - && sameEpisodeStrict(getMediaTitle(), state.expectedTitle) + && sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId) && video.paused && !video.seeking && video.readyState >= 3 @@ -1417,6 +1618,9 @@ } const state = episodeSyncV2State; if (!state || state.transactionId !== transaction.transactionId) return; + if (typeof transaction.expectedEpisodeId === 'string') { + state.expectedEpisodeId = transaction.expectedEpisodeId; + } state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt; if (state.prepareStarted) return; stopEpisodeSyncV2Poll(); @@ -1424,7 +1628,8 @@ state.prepareStarted = true; const video = findVideo(); const currentTitle = getMediaTitle(); - if (!video || !currentTitle || !sameEpisodeStrict(currentTitle, state.expectedTitle)) { + if (!video || !currentTitle + || !sameEpisodeIdentity(currentTitle, state.expectedTitle, state.expectedEpisodeId)) { if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) { retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch'); } @@ -1479,6 +1684,56 @@ clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {}); } + function executeEpisodeSyncV2(transaction) { + const state = episodeSyncV2State; + if (!state || state.transactionId !== transaction.transactionId) { + return Promise.resolve({ status: 'failed_execute', reason: 'stale_transaction' }); + } + if (state.executeResult) return Promise.resolve(state.executeResult); + if (state.executePromise) return state.executePromise; + + const video = state.video || findVideo(); + const current = video ? getSyncCurrentTime(video) : null; + const canExecute = video + && state.phase === 'prepare' + && video === findVideo() + && video.isConnected !== false + && sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId) + && video.paused + && !video.seeking + && video.readyState >= 3 + && current !== null + && Math.abs(current) < 1; + if (!canExecute) { + state.executeResult = { status: 'failed_execute', reason: 'prepared_state_changed' }; + return Promise.resolve(state.executeResult); + } + + state.executePromise = Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => { + if (!isEpisodeSyncV2Current(state)) { + return { status: 'failed_execute', reason: 'stale_transaction' }; + } + if (!applied) { + reportLog('Episode Sync v2 execute could not start playback', 'warn'); + state.executeResult = { status: 'failed_execute', reason: 'play_failed' }; + return state.executeResult; + } + scheduleProactiveHeartbeat(); + state.executeResult = { status: 'executed' }; + return state.executeResult; + }).catch(error => { + reportLog(`Episode Sync v2 execute failed: ${error.message}`, 'warn'); + if (isEpisodeSyncV2Current(state)) { + state.executeResult = { status: 'failed_execute', reason: 'play_failed' }; + return state.executeResult; + } + return { status: 'failed_execute', reason: 'stale_transaction' }; + }).finally(() => { + if (isEpisodeSyncV2Current(state)) state.executePromise = null; + }); + return state.executePromise; + } + function getPlayerActionFixes() { return [ { @@ -1980,36 +2235,51 @@ reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn'); }); } else if (transaction.phase === 'execute') { + const state = episodeSyncV2State; + const video = state?.video || findVideo(); + const current = video ? getSyncCurrentTime(video) : null; + const canExecute = state + && state.transactionId === transaction.transactionId + && state.phase === 'prepare' + && video === findVideo() + && video.isConnected !== false + && sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId) + && video.paused + && !video.seeking + && video.readyState >= 3 + && current !== null + && Math.abs(current) < 1; + if (!canExecute) { + sendResponse({ status: 'failed_execute', reason: 'prepared_state_changed' }); + return true; + } + executeEpisodeSyncV2(transaction).then(sendResponse).catch(() => { + sendResponse({ status: 'failed_execute', reason: 'unexpected_error' }); + }); + return true; + } else if (transaction.phase === 'complete') { if (episodeSyncV2State?.transactionId === transaction.transactionId) { - const state = episodeSyncV2State; - const video = state.video || findVideo(); - const current = video ? getSyncCurrentTime(video) : null; - const canExecute = video - && state.phase === 'prepare' - && video === findVideo() - && video.isConnected !== false - && sameEpisodeStrict(getMediaTitle(), state.expectedTitle) - && video.paused - && !video.seeking - && video.readyState >= 3 - && current !== null - && Math.abs(current) < 1; clearEpisodeSyncV2Content({ resume: false }).catch(() => {}); - if (canExecute) { - Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => { - if (applied) scheduleProactiveHeartbeat(); - else reportLog('Episode Sync v2 execute could not start playback', 'warn'); - }).catch(() => {}); - } else { - reportLog('Episode Sync v2 execute ignored: prepared player state changed', 'warn'); - } } } else if (transaction.phase === 'cancel') { if (episodeSyncV2State?.transactionId === transaction.transactionId) { + const settlePaused = transaction.settlePlaybackState === 'paused'; // A superseding room command follows this cancellation on // the same ordered socket. Do not race it with restoration // of the pre-transaction play state. - clearEpisodeSyncV2Content({ resume: transaction.reason !== 'superseded' }).catch(() => {}); + clearEpisodeSyncV2Content({ + resume: !settlePaused && transaction.reason !== 'superseded' + }).then(async () => { + if (!settlePaused) return; + const video = findVideo(); + if (!video || video.isConnected === false) return; + const paused = video.paused || await tryMediaAction(EVENTS.PAUSE); + const targetTime = Number.isFinite(transaction.targetTime) + ? transaction.targetTime + : 0; + if (paused) await tryMediaAction(EVENTS.SEEK, { targetTime }); + scheduleProactiveHeartbeat(); + }).catch(() => {}); } } sendResponse({ status: 'ok' }); @@ -2215,6 +2485,10 @@ const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; + const episodeSyncIntent = episodeSyncV2State?.phase === 'lobby' + && hcmClassifyIntent() === 'deliberate' + ? 'manual' + : undefined; runtimeMessage({ type: 'CONTENT_EVENT', action, @@ -2222,7 +2496,8 @@ currentTime: current, targetTime: current, mediaTitle: mediaTitle, - timestamp: Date.now() + timestamp: Date.now(), + episodeSyncIntent } }).catch(() => {}); @@ -2230,6 +2505,114 @@ scheduleProactiveHeartbeat(); } + function cancelPlayPauseCoalesce() { + if (playPauseCoalesceTimer) clearTimeout(playPauseCoalesceTimer); + playPauseCoalesceTimer = null; + pendingPlayPauseAction = null; + pendingPlayPauseVideo = null; + } + + function v2LoadingEventIsChurn(video) { + const state = episodeSyncV2State; + if (!state || state.phase !== 'lobby') return false; + if (hcmClassifyIntent() === 'deliberate') return false; + // Before this player has stably reported the expected episode, native + // media events are part of loading by definition. Once loaded, require a + // fresh real input gesture before allowing a command to supersede v2. + if (!state.loadedReported) return true; + if (video !== findVideo() || video.readyState < 3 || video.seeking) return true; + return hcmClassifyIntent() !== 'deliberate'; + } + + function queueEpisodeTransitionEvent(action, video) { + let quarantine = episodeTransitionQuarantine; + if (!quarantine || quarantine.video !== video) { + discardEpisodeTransitionQuarantine(); + quarantine = { + video, + sawPlayback: false, + seekPending: false, + timer: null + }; + episodeTransitionQuarantine = quarantine; + quarantine.timer = setTimeout(flushEpisodeTransitionQuarantine, EPISODE_TRANSITION_QUARANTINE_MS); + } + if (action === EVENTS.PLAY || action === EVENTS.PAUSE) quarantine.sawPlayback = true; + if (action === EVENTS.SEEK) quarantine.seekPending = true; + } + + function flushEpisodeTransitionQuarantine() { + const quarantine = episodeTransitionQuarantine; + if (!quarantine) return; + episodeTransitionQuarantine = null; + if (quarantine.timer) clearTimeout(quarantine.timer); + + const video = quarantine.video; + if (destroyed || pageSuspended || !video || video !== activeVideo || video !== findVideo() + || video.isConnected === false) return; + + // Give a late MediaSession update one final synchronous chance to turn + // this candidate into a confirmed episode transition. + if (checkEpisodeTransition('quarantine_timeout')) return; + if (v2LoadingEventIsChurn(video)) return; + if (episodeTransitionNoiseVideo === video && Date.now() < episodeTransitionNoiseUntil) return; + if (episodeTransitionCandidate?.video === video + && episodeTransitionCandidate.transitionLike) return; + if (episodeTransitionCandidate?.video === video) { + clearEpisodeTransitionCandidate({ commitCurrentTitle: true }); + } + + // No transition materialized within the bounded window: preserve the + // user's final settled intent with fresh time/title rather than replaying + // the stale first edge that opened the quarantine. + if (quarantine.seekPending) sendContentEvent(EVENTS.SEEK, video); + if (quarantine.sawPlayback) { + sendContentEvent(video.paused ? EVENTS.PAUSE : EVENTS.PLAY, video); + } + } + + function shouldQuarantineEpisodeEvent(action, video, current) { + if (!_autoSyncEnabled) return 'relay'; + if (v2LoadingEventIsChurn(video)) return 'quarantine'; + // A fresh pointer/keyboard gesture is explicit user intent, including + // near an episode boundary. Never add transition latency to it. + if (hcmClassifyIntent() === 'deliberate') return 'relay'; + if (episodeTransitionNoiseVideo === video && Date.now() < episodeTransitionNoiseUntil) { + return 'quarantine'; + } + + const title = getMediaTitle(); + const source = getEpisodeSource(video); + const candidate = episodeTransitionCandidate?.video === video + ? episodeTransitionCandidate + : null; + const baselineTitle = candidate?.baselineTitle || lastKnownMediaTitle; + const baselineSource = candidate?.baselineSource !== undefined + ? candidate.baselineSource + : lastVideoSrc; + const titleChanged = !!baselineTitle && !!title && !sameEpisode(title, baselineTitle); + const sourceChanged = baselineSource !== undefined + && !!baselineSource + && !!source + && source !== baselineSource; + const nearBoundary = isNearEpisodeBoundary(video, current); + const automaticTerminal = isAutomaticTerminalEvent(video, current); + + if (!candidate?.quarantineEvents && !titleChanged && !sourceChanged && !nearBoundary) return 'relay'; + const pending = ensureEpisodeTransitionCandidate(video, `media_${action}`, { + baselineSource, + nearBoundary, + transitionLike: automaticTerminal + || titleChanged + || (sourceChanged && (current < 5 || candidate?.nearBoundary === true)), + quarantineEvents: true + }); + if (pending && sourceChanged && current < 5 && pending.nearBoundary) { + pending.transitionLike = true; + } + return checkEpisodeTransition(`media_${action}`) ? 'discard' : 'quarantine'; + } + // Trailing-edge flush of a coalesced play/pause burst: emit the final settled // state. No-ops only when no burst followed the leading edge. We do NOT skip // when the state matches the leading send — a remote command may have changed @@ -2298,6 +2681,13 @@ // Play/Pause pass through — user may want to immediately pause after tabbing back. if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return; + const transitionDisposition = shouldQuarantineEpisodeEvent(action, video, current); + if (transitionDisposition === 'discard') return; + if (transitionDisposition === 'quarantine') { + queueEpisodeTransitionEvent(action, video); + return; + } + // Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The // synchronous gates above have already run; only the network emit is // governed here. Leading edge sends the first event instantly; further @@ -2493,9 +2883,31 @@ let lastVideoSrc = undefined; - // Episode detection handler for loadeddata event + // Episode detection signals deliberately keep polling after either side of + // the common metadata race. Some players update MediaSession before media is + // ready; others fire loadeddata before updating the title. const handleLoadedData = event => { - if (isCurrentVideoEvent(event)) checkEpisodeTransition(); + if (isCurrentVideoEvent(event)) checkEpisodeTransition('loadeddata'); + }; + const handleLoadStart = event => { + if (!isCurrentVideoEvent(event)) return; + cancelPlayPauseCoalesce(); + checkEpisodeTransition('loadstart'); + }; + const handleEmptied = event => { + if (!isCurrentVideoEvent(event)) return; + cancelPlayPauseCoalesce(); + checkEpisodeTransition('emptied'); + }; + const handleEnded = event => { + if (!isCurrentVideoEvent(event) || !_autoSyncEnabled) return; + const video = event.currentTarget; + ensureEpisodeTransitionCandidate(video, 'ended', { + baselineSource: getEpisodeSource(video), + nearBoundary: true, + transitionLike: true, + quarantineEvents: true + }); }; function detachVideoListeners(video) { @@ -2507,6 +2919,9 @@ if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking); video.removeEventListener('seeked', handlers.seeked); video.removeEventListener('loadeddata', handlers.loadeddata); + if (handlers.loadstart) video.removeEventListener('loadstart', handlers.loadstart); + if (handlers.emptied) video.removeEventListener('emptied', handlers.emptied); + if (handlers.ended) video.removeEventListener('ended', handlers.ended); if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting); delete video._koalaHandlers; } @@ -2517,14 +2932,18 @@ function cancelPendingVideoEvents() { if (seekDebounceTimer) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; } - if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; } - pendingPlayPauseAction = null; - pendingPlayPauseVideo = null; + cancelPlayPauseCoalesce(); + discardEpisodeTransitionQuarantine(); + clearEpisodeTransitionCandidate(); + episodeTransitionNoiseVideo = null; + episodeTransitionNoiseUntil = 0; } function setupListeners() { if (destroyed) return; const video = findVideo(); + const previousVideo = activeVideo; + const playerChanged = !!previousVideo && previousVideo !== video; if (activeVideo !== video) cancelPendingVideoEvents(); for (const attached of [...attachedVideos]) { if (attached !== video) detachVideoListeners(attached); @@ -2543,6 +2962,9 @@ seeking: handleSeeking, seeked: handleSeeked, loadeddata: handleLoadedData, + loadstart: handleLoadStart, + emptied: handleEmptied, + ended: handleEnded, waiting: handleWaiting }; video.addEventListener('play', handlePlay); @@ -2550,9 +2972,13 @@ video.addEventListener('seeking', handleSeeking); video.addEventListener('seeked', handleSeeked); video.addEventListener('loadeddata', handleLoadedData); + video.addEventListener('loadstart', handleLoadStart); + video.addEventListener('emptied', handleEmptied); + video.addEventListener('ended', handleEnded); video.addEventListener('waiting', handleWaiting); attachedVideos.add(video); video.dataset.koalaAttached = 'true'; + if (playerChanged) checkEpisodeTransition('player_changed'); lastVideoSrc = video.currentSrc || video.src || null; if (!lastKnownMediaTitle) { @@ -2594,7 +3020,8 @@ if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) { if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) { - checkEpisodeTransition(); + cancelPlayPauseCoalesce(); + checkEpisodeTransition('source_changed'); } setupListeners(); } diff --git a/extension/episode-sync-v2.test.mjs b/extension/episode-sync-v2.test.mjs index f0d7537..a7485c9 100644 --- a/extension/episode-sync-v2.test.mjs +++ b/extension/episode-sync-v2.test.mjs @@ -2,7 +2,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { CAPABILITIES, EVENTS, EPISODE_SYNC_V2_STABILITY_MS } from '../shared/constants.js'; +import { + CAPABILITIES, + EPISODE_LOBBY_TIMEOUT, + EPISODE_SYNC_V2_LOAD_TIMEOUT, + EPISODE_SYNC_V2_PREPARE_TIMEOUT, + EPISODE_SYNC_V2_EXECUTE_TIMEOUT, + EPISODE_SYNC_V2_STABILITY_MS, + EVENTS +} from '../shared/constants.js'; const extensionDir = path.dirname(fileURLToPath(import.meta.url)); const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); @@ -26,7 +34,7 @@ describe('Episode Sync v2 extension contract', () => { expect(backgroundSource).toContain('EVENTS.EPISODE_SYNC_V2'); }); - it('never queues v2 coordination or auto-falls back to a client-owned legacy lobby', () => { + it('keeps v2 live-only and provides a session-correlated legacy fallback', () => { expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2'); const episodeChanged = between( backgroundSource, @@ -34,9 +42,28 @@ describe('Episode Sync v2 extension contract', () => { "message.type === 'EPISODE_READY_LOCAL'" ); expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)'); - expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start'"); - expect(episodeChanged).not.toContain('PAUSE_FOR_LOBBY'); - expect(episodeChanged).not.toContain('emit(EVENTS.EPISODE_LOBBY'); + expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', ...episodeIdentity })"); + expect(episodeChanged).toContain('createPendingEpisodeSyncV2Start(episodeIdentity, sender)'); + expect(episodeChanged).toContain('startLegacyEpisodeLobbyForTransition(episodeIdentity, pending)'); + expect(backgroundSource).toContain("data.reason === 'capability_mismatch'"); + expect(backgroundSource).toContain('isEpisodeSyncV2StartContextCurrent(pending'); + expect(backgroundSource).toContain("emitLive(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle })"); + + const fallback = between( + backgroundSource, + 'function startLegacyEpisodeLobbyForTransition(', + 'function clearTargetTabForIdle(' + ); + expect(fallback.indexOf('if (episodeLobby && sameEpisode(')) + .toBeLessThan(fallback.indexOf('emitLive(EVENTS.EPISODE_LOBBY')); + const rejectionFallback = between( + backgroundSource, + "if (phase === 'cancel' && !data.transactionId)", + "if (phase === 'lobby' || phase === 'prepare')" + ); + expect(rejectionFallback.indexOf('if (!matchesPending)')) + .toBeLessThan(rejectionFallback.indexOf('episodeSyncV2PendingStart = null')); + expect(rejectionFallback).toContain('episodeSyncV2PendingStart = null'); }); it('correlates content reports to the current transaction, phase, target and episode', () => { @@ -48,10 +75,26 @@ describe('Episode Sync v2 extension contract', () => { expect(handler).toContain('message.transactionId !== transaction.transactionId'); expect(handler).toContain('transaction.phase !== expectedLocalPhase'); expect(handler).toContain("!isCurrentContentSender(sender)"); - expect(handler).toContain('!sameEpisodeStrict(localTitle, transaction.expectedTitle)'); + expect(handler).toContain('!sameEpisodeIdentity('); expect(handler).toContain('emitLive(EVENTS.EPISODE_SYNC_V2'); }); + it('separates legacy and v2 loading timeouts and converts remaining duration locally', () => { + expect(EPISODE_LOBBY_TIMEOUT).toBe(60_000); + expect(EPISODE_SYNC_V2_LOAD_TIMEOUT).toBe(120_000); + expect(EPISODE_SYNC_V2_PREPARE_TIMEOUT).toBe(15_000); + expect(EPISODE_SYNC_V2_EXECUTE_TIMEOUT).toBe(10_000); + const normalize = between( + backgroundSource, + 'function normalizeEpisodeSyncV2(', + 'function createPendingEpisodeSyncV2Start(' + ); + expect(normalize).toContain('createLocalEpisodeDeadline(value.remainingMs, phaseTimeout)'); + expect(normalize).toContain('deadlineAt: localDeadline.deadlineAt'); + expect(normalize).not.toContain('value.deadlineAt'); + expect(normalize).toContain("phase === 'prepare' ? EPISODE_SYNC_V2_PREPARE_TIMEOUT : EPISODE_SYNC_V2_EXECUTE_TIMEOUT"); + }); + it('requires the same player and episode to remain paused, seeked, buffered and stable', () => { const stable = between( contentSource, @@ -59,7 +102,7 @@ describe('Episode Sync v2 extension contract', () => { 'async function prepareEpisodeSyncV2(' ); expect(stable).toContain('video === findVideo()'); - expect(stable).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)'); + expect(stable).toContain('sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)'); expect(stable).toContain('video.paused'); expect(stable).toContain('!video.seeking'); expect(stable).toContain('video.readyState >= 3'); @@ -94,7 +137,7 @@ describe('Episode Sync v2 extension contract', () => { expect(clear).toContain('state.wasPlayingBeforePrepare'); expect(clear).toContain('!state.manualAction'); expect(clear).toContain('video === findVideo()'); - expect(clear).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)'); + expect(clear).toContain('sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)'); expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)'); }); @@ -104,14 +147,40 @@ describe('Episode Sync v2 extension contract', () => { "message.type === 'EPISODE_SYNC_V2'", '// Episode Auto-Sync: Legacy lobby notification from background' ); - expect(handler).toContain("resume: transaction.reason !== 'superseded'"); + expect(handler).toContain("transaction.reason !== 'superseded'"); + }); + + it('distinguishes deliberate loading intent and settles execute failures paused', () => { + const eventSender = between( + contentSource, + 'function sendContentEvent(', + 'function cancelPlayPauseCoalesce(' + ); + expect(eventSender).toContain("episodeSyncIntent = episodeSyncV2State?.phase === 'lobby'"); + expect(eventSender).toContain("? 'manual'"); + + const loadingClassifier = between( + contentSource, + 'function v2LoadingEventIsChurn(', + 'function queueEpisodeTransitionEvent(' + ); + expect(loadingClassifier).toContain("hcmClassifyIntent() === 'deliberate'"); + + const handler = between( + contentSource, + "message.type === 'EPISODE_SYNC_V2'", + '// Episode Auto-Sync: Legacy lobby notification from background' + ); + expect(handler).toContain("transaction.settlePlaybackState === 'paused'"); + expect(handler).toContain('await tryMediaAction(EVENTS.PAUSE)'); + expect(handler).toContain('await tryMediaAction(EVENTS.SEEK, { targetTime })'); }); it('revalidates the complete prepared state immediately before execute', () => { const handler = between( contentSource, - "transaction.phase === 'execute'", - "transaction.phase === 'cancel'" + 'function executeEpisodeSyncV2(', + 'function getPlayerActionFixes(' ); expect(handler).toContain("state.phase === 'prepare'"); expect(handler).toContain('video === findVideo()'); @@ -122,8 +191,30 @@ describe('Episode Sync v2 extension contract', () => { expect(handler).toContain('Math.abs(current) < 1'); }); + it('reports content execute outcome and retains state until relay completion', () => { + const execute = between( + backgroundSource, + 'async function executeEpisodeSyncV2FromRelay(', + 'function clearEpisodeSyncV2State(' + ); + expect(execute).toContain("response?.status === 'executed' ? 'executed' : 'failed_execute'"); + expect(execute).toContain('phase: reportStatus'); + expect(execute).not.toContain('clearEpisodeSyncV2State('); + + const handler = between( + backgroundSource, + 'case EVENTS.EPISODE_SYNC_V2:', + 'case EVENTS.EPISODE_LOBBY:' + ); + expect(handler).toContain("phase === 'execute' || phase === 'complete' || phase === 'cancel'"); + expect(handler).toContain('await executeEpisodeSyncV2FromRelay(data)'); + expect(handler).toContain("phase: 'complete'"); + expect(handler).toContain("clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' })"); + }); + it('injects the shared stability window into packaged content scripts', () => { expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS'); expect(buildSource).toContain('episodeSyncStabilityVal'); + expect(buildSource).toContain(".replace(/export const /g, 'const ')"); }); }); diff --git a/extension/episode-utils.js b/extension/episode-utils.js index 5a9ca99..df58924 100644 --- a/extension/episode-utils.js +++ b/extension/episode-utils.js @@ -4,6 +4,16 @@ * Keep in sync with the injection block in content.js! */ +// The relay clamps episode titles to 100 UTF-16 code units. Clamp before the +// wire and before strict comparison as well; otherwise a valid long local title +// can never match the relay's authoritative value. +export const EPISODE_WIRE_TITLE_LENGTH = 100; + +export function toEpisodeWireTitle(title) { + if (typeof title !== 'string' || title.length === 0) return null; + return title.substring(0, EPISODE_WIRE_TITLE_LENGTH); +} + export function extractEpisodeId(title) { if (!title || typeof title !== 'string') return null; const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); @@ -39,8 +49,64 @@ function episodeContext(title) { // it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced // S/E-only titles still fall back to the canonical episode ID. export function sameEpisodeStrict(titleA, titleB) { - if (!sameEpisode(titleA, titleB)) return false; - const contextA = episodeContext(titleA); - const contextB = episodeContext(titleB); - return !contextA || !contextB || contextA === contextB; + const wireTitleA = toEpisodeWireTitle(titleA); + const wireTitleB = toEpisodeWireTitle(titleB); + if (!sameEpisode(wireTitleA, wireTitleB)) return false; + const contextA = episodeContext(wireTitleA); + const contextB = episodeContext(wireTitleB); + if (!contextA || !contextB || contextA === contextB) return true; + // Players disagree about whether MediaSession.title contains the series, + // episode title, or both. Accept a complete token-boundary subset while + // still rejecting unrelated contextual titles for the same S/E number. + const [shorter, longer] = contextA.length <= contextB.length + ? [contextA, contextB] + : [contextB, contextA]; + return shorter.length >= 4 && ` ${longer} `.includes(` ${shorter} `); +} + +export function createEpisodeWireIdentity(title) { + const expectedEpisodeId = extractEpisodeId(title); + const expectedTitle = toEpisodeWireTitle(title); + if (!expectedTitle || !expectedEpisodeId) return null; + return { expectedTitle, expectedEpisodeId }; +} + +export function sameEpisodeIdentity(localTitle, expectedTitle, expectedEpisodeId = null) { + const normalizedExpectedId = typeof expectedEpisodeId === 'string' + ? expectedEpisodeId.trim().toUpperCase().substring(0, 16) + : null; + if (normalizedExpectedId && extractEpisodeId(localTitle) !== normalizedExpectedId) return false; + return sameEpisodeStrict(localTitle, expectedTitle); +} + +export function createLocalEpisodeDeadline(remainingMs, fallbackMs, now = Date.now()) { + const safeFallback = Number.isFinite(fallbackMs) && fallbackMs > 0 ? fallbackMs : 0; + const safeRemaining = Number.isFinite(remainingMs) + ? Math.max(0, Math.min(safeFallback, remainingMs)) + : safeFallback; + return { + remainingMs: safeRemaining, + deadlineAt: now + safeRemaining + }; +} + +export function isEpisodeSyncV2StartContextCurrent(pending, current, now = Date.now()) { + return !!pending + && !!current + && now - pending.requestedAt >= 0 + && now - pending.requestedAt <= 15000 + && pending.roomId === current.roomId + && pending.connectionGeneration === current.connectionGeneration + && pending.targetGeneration === current.targetGeneration + && pending.tabId === current.tabId; +} + +export function matchesEpisodeSyncV2StartRejection(pending, current, rejection, now = Date.now()) { + if (!isEpisodeSyncV2StartContextCurrent(pending, current, now)) return false; + const rejectionTitle = toEpisodeWireTitle(rejection?.expectedTitle); + const rejectionEpisodeId = typeof rejection?.expectedEpisodeId === 'string' + ? rejection.expectedEpisodeId.trim().toUpperCase().substring(0, 16) + : extractEpisodeId(rejectionTitle); + return rejectionTitle === pending.expectedTitle + && (!rejectionEpisodeId || rejectionEpisodeId === pending.expectedEpisodeId); } diff --git a/extension/episode-utils.test.mjs b/extension/episode-utils.test.mjs index d56db18..77631aa 100644 --- a/extension/episode-utils.test.mjs +++ b/extension/episode-utils.test.mjs @@ -1,5 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { extractEpisodeId, sameEpisode, sameEpisodeStrict } from './episode-utils.js'; +import { + EPISODE_WIRE_TITLE_LENGTH, + createLocalEpisodeDeadline, + createEpisodeWireIdentity, + extractEpisodeId, + sameEpisode, + sameEpisodeIdentity, + sameEpisodeStrict, + isEpisodeSyncV2StartContextCurrent, + matchesEpisodeSyncV2StartRejection, + toEpisodeWireTitle +} from './episode-utils.js'; describe('episode title matching', () => { it.each([ @@ -58,5 +69,85 @@ describe('episode title matching', () => { expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S01E06 - Visiting Ours')).toBe(true); expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S1:E6 - Another Show')).toBe(false); expect(sameEpisodeStrict('S1:E6', 'S01E06 - Visiting Ours')).toBe(true); + expect(sameEpisodeStrict( + 'Arrested Development - S1:E6 - Visiting Ours', + 'S01E06 - Visiting Ours' + )).toBe(true); + expect(sameEpisodeStrict( + 'Arrested Development - S1:E6 - Visiting Ours', + 'Different Series - S01E06 - Visiting Ours' + )).toBe(false); + }); + + it('mirrors the relay title clamp before strict comparison', () => { + const title = `S01E06 - ${'Very Long Episode Context '.repeat(8)}`; + const wireTitle = toEpisodeWireTitle(title); + + expect(EPISODE_WIRE_TITLE_LENGTH).toBe(100); + expect(wireTitle).toHaveLength(100); + expect(sameEpisodeStrict(title, wireTitle)).toBe(true); + }); + + it('keeps the full-title episode id in a bounded wire identity', () => { + const title = `${'Long Series Context '.repeat(8)} S01E06`; + const identity = createEpisodeWireIdentity(title); + + expect(identity).toEqual({ + expectedTitle: title.substring(0, 100), + expectedEpisodeId: 'S01E06' + }); + expect(sameEpisodeIdentity(title, identity.expectedTitle, identity.expectedEpisodeId)).toBe(true); + expect(sameEpisodeIdentity(title.replace('S01E06', 'S01E07'), identity.expectedTitle, identity.expectedEpisodeId)).toBe(false); + }); + + it('bounds oversized and UTF-16 titles exactly like relay substring sanitization', () => { + const title = `${'x'.repeat(99)}😀S01E06${'y'.repeat(5000)}`; + expect(toEpisodeWireTitle(title)).toBe(title.substring(0, 100)); + expect(toEpisodeWireTitle(title).length).toBe(100); + expect(toEpisodeWireTitle(null)).toBeNull(); + }); + + it('converts relay remaining duration to a bounded local deadline without wall-clock trust', () => { + expect(createLocalEpisodeDeadline(30_000, 120_000, 1_000)).toEqual({ + remainingMs: 30_000, + deadlineAt: 31_000 + }); + expect(createLocalEpisodeDeadline(undefined, 120_000, 1_000)).toEqual({ + remainingMs: 120_000, + deadlineAt: 121_000 + }); + expect(createLocalEpisodeDeadline(999_999, 120_000, 1_000).remainingMs).toBe(120_000); + expect(createLocalEpisodeDeadline(-1, 120_000, 1_000).remainingMs).toBe(0); + }); + + it('rejects stale or mismatched capability fallback responses', () => { + const pending = { + roomId: 'ROOM-A', + connectionGeneration: 4, + targetGeneration: 7, + tabId: 12, + expectedTitle: 'S01E06 - Visiting Ours', + expectedEpisodeId: 'S01E06', + requestedAt: 10_000 + }; + const current = { + roomId: 'ROOM-A', + connectionGeneration: 4, + targetGeneration: 7, + tabId: 12 + }; + const rejection = { + expectedTitle: 'S01E06 - Visiting Ours', + expectedEpisodeId: 's01e06' + }; + + expect(isEpisodeSyncV2StartContextCurrent(pending, current, 20_000)).toBe(true); + expect(matchesEpisodeSyncV2StartRejection(pending, current, rejection, 20_000)).toBe(true); + expect(matchesEpisodeSyncV2StartRejection(pending, { ...current, targetGeneration: 8 }, rejection, 20_000)).toBe(false); + expect(matchesEpisodeSyncV2StartRejection(pending, current, rejection, 25_001)).toBe(false); + expect(matchesEpisodeSyncV2StartRejection(pending, current, { + ...rejection, + expectedTitle: 'S01E07 - Different' + }, 20_000)).toBe(false); }); }); diff --git a/scripts/build-extension.cjs b/scripts/build-extension.cjs index e21e241..529afd1 100644 --- a/scripts/build-extension.cjs +++ b/scripts/build-extension.cjs @@ -170,6 +170,7 @@ function copyExtensionFiles(targetDir, browserName) { const stripped = euContent .replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '') .replace(/export function /g, 'function ') + .replace(/export const /g, 'const ') .trim(); const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`; content = replaceRequiredBlock(content, euPattern, euRep, 'Episode utils injection'); diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index c137c22..2ed4d3b 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -10,7 +10,7 @@ import { materializeMediaIntent, reserveLatestMediaIntentSequence } from '../extension/offline-media-intent.js'; -import { FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js'; +import { EPISODE_SYNC_V2_LOAD_TIMEOUT, FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); @@ -35,6 +35,23 @@ async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) { assert.equal(event,'room_data'); return data; } +async function advanceEpisodeSyncToExecute(peerA, peerB, expectedTitle, expectedEpisodeId = undefined) { + s(peerA, 'episode_sync_v2', { phase: 'start', expectedTitle, expectedEpisodeId }); + const lobby = await w(peerA, 'episode_sync_v2'); + await w(peerB, 'episode_sync_v2'); + s(peerA, 'episode_sync_v2', { phase: 'loaded', transactionId: lobby.transactionId }); + await w(peerA, 'episode_sync_v2'); await w(peerB, 'episode_sync_v2'); + s(peerB, 'episode_sync_v2', { phase: 'loaded', transactionId: lobby.transactionId }); + await w(peerA, 'episode_sync_v2'); await w(peerB, 'episode_sync_v2'); + s(peerA, 'episode_sync_v2', { phase: 'prepared', transactionId: lobby.transactionId }); + await w(peerA, 'episode_sync_v2'); await w(peerB, 'episode_sync_v2'); + s(peerB, 'episode_sync_v2', { phase: 'prepared', transactionId: lobby.transactionId }); + const executeA = await w(peerA, 'episode_sync_v2'); + const executeB = await w(peerB, 'episode_sync_v2'); + assert.equal(executeA.phase, 'execute'); + assert.equal(executeB.phase, 'execute'); + return { lobby, executeA, executeB }; +} const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; } // Test suite opens >10 connections/min — clear the IP connection counter so the @@ -208,13 +225,22 @@ try { await j(episodeB, episodeRid, 'episode-b', null, episodeCaps); episodeA._m.length = episodeB._m.length = 0; - s(episodeA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E06 - Visiting Ours' }); + s(episodeA, 'episode_sync_v2', { + phase: 'start', + expectedTitle: 'S01E06 - Visiting Ours', + expectedEpisodeId: 's01e06' + }); const episodeLobbyA = await w(episodeA, 'episode_sync_v2'); const episodeLobbyB = await w(episodeB, 'episode_sync_v2'); assert.equal(episodeLobbyA.phase, 'lobby'); assert.equal(episodeLobbyA.transactionId, episodeLobbyB.transactionId); assert.deepEqual(episodeLobbyA.participants, ['episode-a', 'episode-b']); assert.deepEqual(episodeLobbyA.loadedPeers, [], 'initiator is not pre-marked loaded'); + assert.equal(episodeLobbyA.expectedEpisodeId, 'S01E06'); + assert.ok(episodeLobbyA.remainingMs > 0 && episodeLobbyA.remainingMs <= EPISODE_SYNC_V2_LOAD_TIMEOUT); + assert.ok(episodeLobbyA.remainingMs > 119_000, + 'v2 loading advertises the independent 120s deadline, not the 60s legacy deadline'); + assert.equal(episodeLobbyA.deadlineAt, undefined, 'relay wall clock is not exposed to clients'); const episodeTxId = episodeLobbyA.transactionId; s(episodeB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S99E99' }); @@ -262,12 +288,47 @@ try { const executeB = await w(episodeB, 'episode_sync_v2'); assert.equal(executeA.phase, 'execute'); assert.equal(executeB.phase, 'execute'); - assert.equal(mod.rooms.get(episodeRid).episodeSyncV2, null); - assert.equal(mod.rooms.get(episodeRid).mediaState.playbackState, 'playing'); - assert.equal(mod.rooms.get(episodeRid).mediaState.currentTime, 0); + assert.equal(mod.rooms.get(episodeRid).episodeSyncV2.phase, 'executing'); + assert.equal(mod.rooms.get(episodeRid).mediaState, null, + 'canonical playing is not committed before every execute ACK'); let duplicateExecute = false; try { await w(episodeB, 'episode_sync_v2', 300); duplicateExecute = true; } catch { /* expected */ } assert.equal(duplicateExecute, false, 'execute is emitted exactly once'); + + s(episodeA, 'episode_sync_v2', { phase: 'executed', transactionId: episodeTxId }); + await delay(50); + assert.deepEqual(mod.rooms.get(episodeRid).episodeSyncV2.executedPeers, ['episode-a']); + assert.equal(mod.rooms.get(episodeRid).mediaState, null, + 'one execute ACK cannot complete the room'); + s(episodeA, 'episode_sync_v2', { phase: 'executed', transactionId: episodeTxId }); + await delay(50); + assert.deepEqual(mod.rooms.get(episodeRid).episodeSyncV2.executedPeers, ['episode-a'], + 'duplicate execute ACK is idempotent'); + s(episodeB, 'episode_sync_v2', { phase: 'executed', transactionId: episodeTxId }); + const completeA = await w(episodeA, 'episode_sync_v2'); + const completeB = await w(episodeB, 'episode_sync_v2'); + assert.equal(completeA.phase, 'complete'); + assert.equal(completeB.phase, 'complete'); + assert.deepEqual(completeA.executedPeers, ['episode-a', 'episode-b']); + assert.equal(completeA.remainingMs, 0); + assert.equal(mod.rooms.get(episodeRid).episodeSyncV2, null); + assert.equal(mod.rooms.get(episodeRid).mediaState.playbackState, 'playing'); + assert.equal(mod.rooms.get(episodeRid).mediaState.currentTime, 0); + + s(episodeA, 'episode_sync_v2', { + phase: 'start', + expectedTitle: 'S01E07', + expectedEpisodeId: '