fix(sync): harden episode transition compatibility

This commit is contained in:
Timo
2026-09-01 07:08:39 +02:00
parent 742386064c
commit dc7eb86ce6
14 changed files with 1625 additions and 220 deletions
+13
View File
@@ -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.
+49 -24
View File
@@ -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.
+242 -28
View File
@@ -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;
}
+480 -53
View File
@@ -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();
}
+102 -11
View File
@@ -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 ')");
});
});
+70 -4
View File
@@ -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);
}
+92 -1
View File
@@ -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);
});
});
+1
View File
@@ -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');
+196 -47
View File
@@ -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: '<script>S01E07'
});
const sanitizedEpisodeLobby = await w(episodeA, 'episode_sync_v2');
await w(episodeB, 'episode_sync_v2');
assert.equal(sanitizedEpisodeLobby.expectedEpisodeId, null,
'invalid episode identity is removed instead of reflected');
s(episodeA, 'episode_sync_v2', {
phase: 'cancel',
transactionId: sanitizedEpisodeLobby.transactionId
});
await w(episodeA, 'episode_sync_v2'); await w(episodeB, 'episode_sync_v2');
close();
resetConnectionRate();
@@ -311,25 +372,81 @@ try {
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'play', { currentTime: 2, seq: 1 });
const manualCancelA = await w(episodeAbortA, 'episode_sync_v2');
const manualCancelB = await w(episodeAbortB, 'episode_sync_v2');
const manualPlayB = await w(episodeAbortB, 'play');
assert.equal(manualCancelA.reason, 'superseded');
assert.equal(manualCancelB.reason, 'superseded');
assert.equal(manualPlayB.currentTime, 2);
const loadingPlayB = await w(episodeAbortB, 'play');
assert.equal(loadingPlayB.currentTime, 2);
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2.phase, 'loading',
'raw source-swap playback does not cancel the loading barrier');
s(episodeAbortA, 'episode_sync_v2', {
phase: 'cancel',
transactionId: mod.rooms.get(episodeAbortRid).episodeSyncV2.transactionId
});
const explicitCancelA = await w(episodeAbortA, 'episode_sync_v2');
const explicitCancelB = await w(episodeAbortB, 'episode_sync_v2');
assert.equal(explicitCancelA.reason, 'peer_cancelled');
assert.equal(explicitCancelB.reason, 'peer_cancelled');
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02-MANUAL' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'pause', { currentTime: 3, seq: 2, episodeSyncIntent: 'manual' });
const loadingManualCancelA = await w(episodeAbortA, 'episode_sync_v2');
const loadingManualCancelB = await w(episodeAbortB, 'episode_sync_v2');
const loadingManualPauseB = await w(episodeAbortB, 'pause');
assert.equal(loadingManualCancelA.reason, 'superseded');
assert.equal(loadingManualCancelB.reason, 'superseded');
assert.equal(loadingManualPauseB.currentTime, 3);
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null,
'an explicitly classified manual action supersedes loading');
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E03' });
const prepareLobby = await w(episodeAbortA, 'episode_sync_v2');
await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'episode_sync_v2', { phase: 'loaded', transactionId: prepareLobby.transactionId });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortB, 'episode_sync_v2', { phase: 'loaded', transactionId: prepareLobby.transactionId });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'pause', { currentTime: 0, seq: 3 });
const prepareCancelA = await w(episodeAbortA, 'episode_sync_v2');
const prepareCancelB = await w(episodeAbortB, 'episode_sync_v2');
const preparePauseB = await w(episodeAbortB, 'pause');
assert.equal(prepareCancelA.reason, 'superseded');
assert.equal(prepareCancelB.reason, 'superseded');
assert.equal(preparePauseB.currentTime, 0);
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E04' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'force_sync_prepare', { targetTime: 44, seq: 4 });
const forceCancelA = await w(episodeAbortA, 'episode_sync_v2');
const forceCancelB = await w(episodeAbortB, 'episode_sync_v2');
const forcePrepareB = await w(episodeAbortB, 'force_sync_prepare');
assert.equal(forceCancelA.reason, 'superseded');
assert.equal(forceCancelB.reason, 'superseded');
assert.equal(forcePrepareB.targetTime, 44);
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
s(episodeAbortA, 'force_sync_execute', { seq: 5 });
await w(episodeAbortB, 'force_sync_execute');
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E05' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortB, 'peer_status', { status: 'heartbeat', desynced: true });
const desyncCancelA = await w(episodeAbortA, 'episode_sync_v2');
const desyncCancelB = await w(episodeAbortB, 'episode_sync_v2');
assert.equal(desyncCancelA.reason, 'participant_desynced');
assert.equal(desyncCancelB.failedPeerId, 'abort-b');
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
const abortRoom = mod.rooms.get(episodeAbortRid);
abortRoom.controlMode = 'host-only';
abortRoom.controllers = new Set(['abort-a']);
s(episodeAbortB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E03' });
s(episodeAbortB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E06' });
const guestStartCancel = await w(episodeAbortB, 'episode_sync_v2');
assert.equal(guestStartCancel.reason, 'not_controller');
assert.equal(abortRoom.episodeSyncV2, null);
close();
resetConnectionRate();
// Frozen participants: a late legacy joiner is excluded and does not block completion.
// Membership is frozen: every successful late join or reconnect cancels
// instead of reusing ACKs from a stale participant/socket snapshot.
const frozenRid = 'episode-frozen-'+Date.now();
const frozenA = await c(), frozenB = await c();
await j(frozenA, frozenRid, 'frozen-a', null, episodeCaps);
@@ -342,20 +459,26 @@ try {
const frozenLateRoom = await j(frozenLate, frozenRid, 'frozen-late');
assert.equal(frozenLateRoom.episodeSyncV2, null);
assert.deepEqual(frozenLobbyA.participants, ['frozen-a', 'frozen-b']);
frozenLate._m.length = 0;
for (const peer of [frozenA, frozenB]) {
s(peer, 'episode_sync_v2', { phase: 'loaded', transactionId: frozenLobbyA.transactionId });
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); }
}
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2');
for (const peer of [frozenA, frozenB]) {
s(peer, 'episode_sync_v2', { phase: 'prepared', transactionId: frozenLobbyA.transactionId });
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); }
}
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2');
let lateReceivedV2 = false;
try { await w(frozenLate, 'episode_sync_v2', 300); lateReceivedV2 = true; } catch { /* expected */ }
assert.equal(lateReceivedV2, false);
const lateCancelA = await w(frozenA, 'episode_sync_v2');
const lateCancelB = await w(frozenB, 'episode_sync_v2');
assert.equal(lateCancelA.reason, 'membership_changed');
assert.equal(lateCancelB.reason, 'membership_changed');
assert.equal(mod.rooms.get(frozenRid).episodeSyncV2, null);
s(frozenLate, 'leave_room', {});
await w(frozenA, 'peer_status');
await w(frozenB, 'peer_status');
frozenA._m.length = frozenB._m.length = 0;
s(frozenA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E08' });
await w(frozenA, 'episode_sync_v2');
await w(frozenB, 'episode_sync_v2');
const frozenBRejoin = await c();
const rejoinRoom = await j(frozenBRejoin, frozenRid, 'frozen-b', null, episodeCaps);
assert.equal(rejoinRoom.episodeSyncV2, null);
const rejoinCancelA = await w(frozenA, 'episode_sync_v2');
assert.equal(rejoinCancelA.reason, 'membership_changed');
assert.equal(rejoinCancelA.failedPeerId, 'frozen-b');
assert.equal(mod.rooms.get(frozenRid).episodeSyncV2, null);
close();
resetConnectionRate();
@@ -377,7 +500,32 @@ try {
assert.equal(timeoutCancelB.phase, 'cancel');
assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null);
s(cancelA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E10' });
const failedExecute = await advanceEpisodeSyncToExecute(cancelA, cancelB, 'S01E10', 'S01E10');
s(cancelB, 'episode_sync_v2', {
phase: 'failed_execute',
transactionId: failedExecute.lobby.transactionId
});
const failedExecuteCancelA = await w(cancelA, 'episode_sync_v2');
const failedExecuteCancelB = await w(cancelB, 'episode_sync_v2');
assert.equal(failedExecuteCancelA.reason, 'execute_failed');
assert.equal(failedExecuteCancelB.failedPeerId, 'cancel-b');
assert.equal(failedExecuteCancelA.settlePlaybackState, 'paused');
assert.equal(failedExecuteCancelA.targetTime, 0);
assert.equal(mod.rooms.get(cancelRid).mediaState.playbackState, 'paused');
assert.equal(mod.rooms.get(cancelRid).mediaState.currentTime, 0);
const executeTimeout = await advanceEpisodeSyncToExecute(cancelA, cancelB, 'S01E11', 'S01E11');
mod.rooms.get(cancelRid).episodeSyncV2.deadlineAt = 1;
mod.expireEpisodeSyncV2Transactions(Date.now());
const executeTimeoutCancelA = await w(cancelA, 'episode_sync_v2');
const executeTimeoutCancelB = await w(cancelB, 'episode_sync_v2');
assert.equal(executeTimeoutCancelA.reason, 'execute_timeout');
assert.equal(executeTimeoutCancelB.settlePlaybackState, 'paused');
assert.equal(executeTimeoutCancelA.transactionId, executeTimeout.lobby.transactionId);
assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null);
assert.equal(mod.rooms.get(cancelRid).mediaState.playbackState, 'paused');
s(cancelA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E12' });
const departureLobby = await w(cancelA, 'episode_sync_v2'); await w(cancelB, 'episode_sync_v2');
assert.notEqual(cancelLobby.transactionId, departureLobby.transactionId, 'new transaction receives a fresh identity');
s(cancelB, 'leave_room', {});
@@ -389,27 +537,22 @@ try {
close();
resetConnectionRate();
// New relay hardening for old extensions: only the accepted lobby owner may
// relay legacy PREPARE/EXECUTE/CANCEL.
const legacyEpisodeRid = 'episode-legacy-owner-'+Date.now();
// Origin/main compatibility: legacy clients did not distinguish automatic
// and manual Force Sync. A non-lobby-owner command must therefore keep its
// historical relay semantics instead of being silently discarded.
const legacyEpisodeRid = 'episode-legacy-compat-'+Date.now();
const legacyEpisodeA = await c(), legacyEpisodeB = await c();
await j(legacyEpisodeA, legacyEpisodeRid, 'legacy-a');
await j(legacyEpisodeB, legacyEpisodeRid, 'legacy-b');
legacyEpisodeA._m.length = legacyEpisodeB._m.length = 0;
s(legacyEpisodeA, 'episode_lobby', { expectedTitle: 'S01E11' });
await w(legacyEpisodeB, 'episode_lobby');
s(legacyEpisodeB, 'force_sync_prepare', { targetTime: 0 });
let nonOwnerPrepareRelayed = false;
try { await w(legacyEpisodeA, 'force_sync_prepare', 300); nonOwnerPrepareRelayed = true; } catch { /* expected */ }
assert.equal(nonOwnerPrepareRelayed, false);
s(legacyEpisodeA, 'force_sync_prepare', { targetTime: 0 });
await w(legacyEpisodeB, 'force_sync_prepare');
s(legacyEpisodeB, 'force_sync_prepare', { targetTime: 17 });
const nonOwnerPrepare = await w(legacyEpisodeA, 'force_sync_prepare');
assert.equal(nonOwnerPrepare.targetTime, 17);
s(legacyEpisodeB, 'force_sync_execute', {});
let nonOwnerExecuteRelayed = false;
try { await w(legacyEpisodeA, 'force_sync_execute', 300); nonOwnerExecuteRelayed = true; } catch { /* expected */ }
assert.equal(nonOwnerExecuteRelayed, false);
s(legacyEpisodeA, 'force_sync_execute', {});
await w(legacyEpisodeB, 'force_sync_execute');
await w(legacyEpisodeA, 'force_sync_execute');
assert.equal(mod.rooms.get(legacyEpisodeRid).mediaState.currentTime, 17);
close();
resetConnectionRate();
@@ -1238,12 +1381,18 @@ try {
mxo._m.length = mxn._m.length = 0;
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
s(mxn,'pause',{currentTime:2}); await w(mxo,'episode_lobby_cancel'); await w(mxo,'pause');
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
assert.equal(mod.rooms.get(mxrid).activeLobby.expectedTitle, 'S1E1',
'legacy lobby survives ordinary PAUSE from an old/new peer');
let transitionPauseCancelledLobby = false;
try { await w(mxo,'episode_lobby_cancel',300); transitionPauseCancelledLobby = true; } catch { /* expected */ }
assert.equal(transitionPauseCancelledLobby, false);
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
assert.equal(mod.rooms.get(mxrid).activeLobby.expectedTitle, 'S1E1',
'legacy lobby survives source-swap SEEK');
s(mxn,'episode_lobby_cancel',{});
let staleMixedCancelDropped = false;
try { await w(mxo,'episode_lobby_cancel',300); } catch { staleMixedCancelDropped = true; }
assert.ok(staleMixedCancelDropped, 'stale legacy lobby cancel is dropped after manual playback supersedes it');
await w(mxo,'episode_lobby_cancel');
assert.equal(mod.rooms.get(mxrid).activeLobby, null);
close();
resetConnectionRate();
@@ -1305,7 +1454,7 @@ try {
console.log('All WebSocket integration tests passed (incl. host control mode)');
} catch(e) {
console.error('FAILED:', e.message);
console.error('FAILED:', e.stack || e.message);
process.exitCode=1;
} finally {
close();
+121 -50
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME, EPISODE_LOBBY_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT } from '../shared/constants.js';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME, EPISODE_SYNC_V2_LOAD_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT, EPISODE_SYNC_V2_EXECUTE_TIMEOUT } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
commitForceSyncMediaState,
@@ -189,15 +189,18 @@ const SEQUENCED_ROOM_EVENTS = new Set([
EVENTS.FORCE_SYNC_EXECUTE
]);
const EPISODE_SYNC_V2_SUPERSEDING_EVENTS = new Set([
EVENTS.PLAY,
EVENTS.PAUSE,
EVENTS.SEEK,
const EPISODE_SYNC_V2_HARD_SUPERSEDING_EVENTS = new Set([
EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_EXECUTE,
EVENTS.EPISODE_LOBBY
]);
const EPISODE_SYNC_V2_PREPARE_SUPERSEDING_EVENTS = new Set([
EVENTS.PLAY,
EVENTS.PAUSE,
EVENTS.SEEK
]);
// Features this relay supports, advertised to clients in ROOM_DATA so they can
// enable matching UI/behavior only when the server actually backs it. Append a
// flag here when a new server-gated feature ships (e.g. co-host promotion).
@@ -236,19 +239,33 @@ function clientSupportsEpisodeSyncV2(socket) {
&& socket.data.clientCapabilities.includes(CAPABILITIES.EPISODE_SYNC_V2);
}
function normalizeExpectedEpisodeId(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().toUpperCase().substring(0, 16);
return /^(?:S\d{1,4}E\d{1,4}|EP\d{1,6})$/.test(normalized)
? normalized
: null;
}
function publicEpisodeSyncV2(transaction, phase = null) {
if (!transaction) return null;
const publicPhase = phase || (transaction.phase === 'loading' ? 'lobby' : 'prepare');
const publicPhase = phase || (transaction.phase === 'loading'
? 'lobby'
: (transaction.phase === 'preparing' ? 'prepare' : 'execute'));
return {
transactionId: transaction.transactionId,
phase: publicPhase,
expectedTitle: transaction.expectedTitle,
expectedEpisodeId: transaction.expectedEpisodeId || null,
initiatorPeerId: transaction.initiatorPeerId,
participants: [...transaction.participants],
loadedPeers: [...transaction.loadedPeers],
preparedPeers: [...transaction.preparedPeers],
executedPeers: [...(transaction.executedPeers || [])],
createdAt: transaction.createdAt,
deadlineAt: transaction.deadlineAt,
remainingMs: Number.isFinite(transaction.deadlineAt)
? Math.max(0, transaction.deadlineAt - Date.now())
: 0,
revision: transaction.revision
};
}
@@ -269,17 +286,30 @@ function clearEpisodeSyncV2Timer(transaction) {
transaction.timeout = null;
}
function cancelEpisodeSyncV2(roomId, room, reason, failedPeerId = null) {
function cancelEpisodeSyncV2(roomId, room, reason, failedPeerId = null, { settlePaused = false } = {}) {
const transaction = room?.episodeSyncV2;
if (!transaction) return false;
clearEpisodeSyncV2Timer(transaction);
const shouldSettlePaused = settlePaused || transaction.phase === 'executing';
if (shouldSettlePaused) {
updateMediaStateFromControl(
room,
EVENTS.PAUSE,
{ currentTime: 0, mediaTitle: transaction.expectedTitle },
transaction.initiatorPeerId,
{ now: Date.now(), senderPlaybackState: 'paused', senderMediaTitle: transaction.expectedTitle }
);
}
room.episodeSyncV2 = null;
room.lastEpisodeSyncV2Id = transaction.transactionId;
emitEpisodeSyncV2ToParticipants(roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'cancel'),
senderId: transaction.initiatorPeerId,
reason: typeof reason === 'string' ? reason.substring(0, 32) : 'cancelled',
failedPeerId: typeof failedPeerId === 'string' ? failedPeerId.substring(0, 16) : undefined
failedPeerId: typeof failedPeerId === 'string' ? failedPeerId.substring(0, 16) : undefined,
settlePlaybackState: shouldSettlePaused ? 'paused' : undefined,
targetTime: shouldSettlePaused ? 0 : undefined,
remainingMs: 0
});
return true;
}
@@ -289,7 +319,12 @@ function scheduleEpisodeSyncV2Deadline(roomId, room, transaction, timeoutMs) {
transaction.deadlineAt = Date.now() + timeoutMs;
transaction.timeout = setTimeout(() => {
if (room.episodeSyncV2 !== transaction) return;
cancelEpisodeSyncV2(roomId, room, transaction.phase === 'loading' ? 'load_timeout' : 'prepare_timeout');
const timeoutReason = transaction.phase === 'loading'
? 'load_timeout'
: (transaction.phase === 'preparing' ? 'prepare_timeout' : 'execute_timeout');
cancelEpisodeSyncV2(roomId, room, timeoutReason, null, {
settlePaused: transaction.phase === 'executing'
});
}, timeoutMs);
transaction.timeout.unref?.();
}
@@ -306,7 +341,12 @@ export function expireEpisodeSyncV2Transactions(now = Date.now()) {
for (const [roomId, room] of rooms) {
const transaction = room.episodeSyncV2;
if (transaction && Number.isFinite(transaction.deadlineAt) && transaction.deadlineAt <= now) {
cancelEpisodeSyncV2(roomId, room, transaction.phase === 'loading' ? 'load_timeout' : 'prepare_timeout');
const timeoutReason = transaction.phase === 'loading'
? 'load_timeout'
: (transaction.phase === 'preparing' ? 'prepare_timeout' : 'execute_timeout');
cancelEpisodeSyncV2(roomId, room, timeoutReason, null, {
settlePaused: transaction.phase === 'executing'
});
}
}
}
@@ -357,7 +397,7 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
// V2 freezes its participant set. Losing any participant invalidates the
// barrier; continuing with a smaller set could execute before a reconnecting
// player has actually prepared.
if (room.episodeSyncV2?.participants.includes(peerId) && !peerJoinLocks.has(peerId)) {
if (room.episodeSyncV2?.participants.includes(peerId)) {
cancelEpisodeSyncV2(roomId, room, 'participant_left', peerId);
}
@@ -385,10 +425,8 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
room.activeLobby = null; // Dissolve lobby
room.legacyEpisodeSyncOwner = null;
}
}
if (room.legacyEpisodeSyncOwner === peerId) room.legacyEpisodeSyncOwner = null;
// 3.6. Host Control Mode: if the host left (and isn't still connected via another
// socket), fall back to 'everyone' so the room never gets stuck locked, and
@@ -530,6 +568,9 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
if (rooms.get(roomId)?.episodeSyncV2) {
cancelEpisodeSyncV2(roomId, rooms.get(roomId), 'membership_changed', peerId);
}
socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam
}
@@ -597,7 +638,6 @@ io.on('connection', (socket) => {
// transaction explicitly replaced by newer room playback.
forceSyncSuperseded: false,
activeLobby: null,
legacyEpisodeSyncOwner: null,
episodeSyncV2: null,
lastEpisodeSyncV2Id: null
};
@@ -646,6 +686,14 @@ io.on('connection', (socket) => {
return;
}
// A v2 barrier freezes the exact socket membership at START.
// Any successful late join or reconnect invalidates that
// snapshot; cancel before dedupe/add so no prepared/executed ACK
// from the previous membership can complete the transaction.
if (room.episodeSyncV2) {
cancelEpisodeSyncV2(roomId, room, 'membership_changed', peerId);
}
// Peer Deduplication: Remove existing socket for the same peerId
const dedupeSids = [];
for (const [sid, data] of room.peerData.entries()) {
@@ -865,23 +913,20 @@ io.on('connection', (socket) => {
return;
}
// A user/manual legacy command wins over automation. Cancel
// the v2 barrier first so prepared peers can restore safely,
// then relay the newer command normally.
if (room.episodeSyncV2 && EPISODE_SYNC_V2_SUPERSEDING_EVENTS.has(eventName)) {
cancelEpisodeSyncV2(mapping.roomId, room, 'superseded', mapping.peerId);
}
// Legacy clients all used to self-promote after lobby ready.
// Preserve their wire contract, but bind the room-wide PREPARE,
// EXECUTE and CANCEL to the accepted lobby owner.
if (room.legacyEpisodeSyncOwner
&& (eventName === EVENTS.FORCE_SYNC_PREPARE
|| eventName === EVENTS.FORCE_SYNC_EXECUTE
|| eventName === EVENTS.EPISODE_LOBBY_CANCEL)
&& mapping.peerId !== room.legacyEpisodeSyncOwner) {
log('ROOM', `Dropped legacy episode ${eventName} from non-owner ${mapping.peerId}`);
return;
// Loading deliberately tolerates raw player PLAY/PAUSE/SEEK
// churn from source swaps. During PREPARE those commands are
// newer room intent and release already-paused peers. Force
// Sync and an explicit legacy lobby supersede every phase.
if (room.episodeSyncV2) {
const hardSupersede = EPISODE_SYNC_V2_HARD_SUPERSEDING_EVENTS.has(eventName);
const deliberateLoadingSupersede = room.episodeSyncV2.phase === 'loading'
&& data?.episodeSyncIntent === 'manual'
&& EPISODE_SYNC_V2_PREPARE_SUPERSEDING_EVENTS.has(eventName);
const prepareSupersede = room.episodeSyncV2.phase === 'preparing'
&& EPISODE_SYNC_V2_PREPARE_SUPERSEDING_EVENTS.has(eventName);
if (hardSupersede || deliberateLoadingSupersede || prepareSupersede) {
cancelEpisodeSyncV2(mapping.roomId, room, 'superseded', mapping.peerId);
}
}
const mediaStateNow = Date.now();
@@ -911,16 +956,6 @@ io.on('connection', (socket) => {
room.forceSyncSuperseded = true;
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
if (canonicalStateUpdated) room.legacyEpisodeSyncOwner = null;
}
if (canonicalStateUpdated && room.activeLobby) {
room.activeLobby = null;
room.legacyEpisodeSyncOwner = null;
io.to(mapping.roomId).emit(EVENTS.EPISODE_LOBBY_CANCEL, {
senderId: mapping.peerId,
peerId: mapping.peerId,
reason: 'superseded'
});
}
if (eventName === EVENTS.FORCE_SYNC_PREPARE) {
// A malformed PREPARE must neither pause peers nor grant
@@ -971,7 +1006,6 @@ io.on('connection', (socket) => {
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
room.forceSyncSuperseded = false;
room.legacyEpisodeSyncOwner = null;
}
socket.to(mapping.roomId).emit(eventName, relayPayload);
@@ -983,14 +1017,12 @@ io.on('connection', (socket) => {
initiatorPeerId: mapping.peerId,
readyPeers: [mapping.peerId]
};
room.legacyEpisodeSyncOwner = mapping.peerId;
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
room.activeLobby.readyPeers.push(mapping.peerId);
}
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
room.activeLobby = null;
if (eventName === EVENTS.EPISODE_LOBBY_CANCEL) room.legacyEpisodeSyncOwner = null;
}
}
}
@@ -1022,6 +1054,7 @@ io.on('connection', (socket) => {
const expectedTitle = typeof data.expectedTitle === 'string'
? data.expectedTitle.substring(0, 100)
: '';
const expectedEpisodeId = normalizeExpectedEpisodeId(data.expectedEpisodeId);
const isController = room.controlMode !== CONTROL_MODES.HOST_ONLY
|| (room.controllers && room.controllers.has(mapping.peerId));
const senderData = room.peerData.get(socket.id);
@@ -1030,6 +1063,7 @@ io.on('connection', (socket) => {
transactionId: null,
senderId: mapping.peerId,
expectedTitle,
expectedEpisodeId,
reason
});
if (!expectedTitle) return rejectStart('invalid_title');
@@ -1062,17 +1096,19 @@ io.on('connection', (socket) => {
transactionId: crypto.randomUUID(),
phase: 'loading',
expectedTitle,
expectedEpisodeId,
initiatorPeerId: mapping.peerId,
participants,
loadedPeers: [],
preparedPeers: [],
executedPeers: [],
createdAt: now,
deadlineAt: now + EPISODE_LOBBY_TIMEOUT,
deadlineAt: now + EPISODE_SYNC_V2_LOAD_TIMEOUT,
revision: 1,
timeout: null
};
room.episodeSyncV2 = transaction;
scheduleEpisodeSyncV2Deadline(mapping.roomId, room, transaction, EPISODE_LOBBY_TIMEOUT);
scheduleEpisodeSyncV2Deadline(mapping.roomId, room, transaction, EPISODE_SYNC_V2_LOAD_TIMEOUT);
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'lobby'),
senderId: transaction.initiatorPeerId
@@ -1089,7 +1125,19 @@ io.on('connection', (socket) => {
}
if (phase === 'cancel' || phase === 'failed') {
cancelEpisodeSyncV2(mapping.roomId, room, phase === 'failed' ? 'peer_failed' : 'peer_cancelled', mapping.peerId);
cancelEpisodeSyncV2(
mapping.roomId,
room,
phase === 'failed' ? 'peer_failed' : 'peer_cancelled',
mapping.peerId
);
return;
}
if (phase === 'failed_execute' && transaction.phase === 'executing') {
cancelEpisodeSyncV2(mapping.roomId, room, 'execute_failed', mapping.peerId, {
settlePaused: true
});
return;
}
@@ -1129,6 +1177,28 @@ io.on('connection', (socket) => {
return;
}
transaction.phase = 'executing';
transaction.revision++;
scheduleEpisodeSyncV2Deadline(
mapping.roomId,
room,
transaction,
EPISODE_SYNC_V2_EXECUTE_TIMEOUT
);
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'execute'),
senderId: transaction.initiatorPeerId,
targetTime: 0
});
return;
}
if (phase === 'executed' && transaction.phase === 'executing') {
if (transaction.executedPeers.includes(mapping.peerId)) return;
transaction.executedPeers.push(mapping.peerId);
transaction.revision++;
if (transaction.executedPeers.length < transaction.participants.length) return;
clearEpisodeSyncV2Timer(transaction);
room.episodeSyncV2 = null;
room.lastEpisodeSyncV2Id = transaction.transactionId;
@@ -1143,9 +1213,10 @@ io.on('connection', (socket) => {
transaction.expectedTitle
);
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'execute'),
...publicEpisodeSyncV2(transaction, 'complete'),
senderId: transaction.initiatorPeerId,
targetTime: 0
targetTime: 0,
remainingMs: 0
});
}
} catch (err) {
+4 -2
View File
@@ -32,7 +32,7 @@ Browser extensions cannot import files outside their own root directory, so the
- `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority.
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots.
- `CAPABILITIES.EPISODE_SYNC_V2`: relay owns an ID-correlated load/prepare/execute episode barrier.
- `CAPABILITIES.EPISODE_SYNC_V2`: relay owns an ID-correlated load/prepare/execute/complete episode barrier.
Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`.
@@ -58,7 +58,7 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition lobby started |
| `EPISODE_READY` | Bidirectional relay | Peer has loaded the episode and is ready |
| `EPISODE_LOBBY_CANCEL` | Bidirectional relay | Active episode lobby cancelled |
| `EPISODE_SYNC_V2` | Bidirectional, capability-gated | Relay-authoritative episode transaction (`start/lobby/loaded/prepare/prepared/execute/cancel`) |
| `EPISODE_SYNC_V2` | Bidirectional, capability-gated | Relay-authoritative episode transaction (`start/lobby/loaded/prepare/prepared/execute/executed/complete/cancel`) |
| `GET_ROOMS` / `ROOM_LIST` | Client <-> Server | Room discovery with server-side cooldown |
| `PING` / `PONG` | Client <-> Server/Peer | Server RTT and peer latency checks |
@@ -68,7 +68,9 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
- `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs.
- `FORCE_SYNC_TARGET_DELAY_WARNING`: threshold for logging delayed Force Sync execution; an unsuperseded prepared target remains executable for receiver liveness.
- `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness.
- `EPISODE_SYNC_V2_LOAD_TIMEOUT`: relay-owned loading window for the v2 barrier.
- `EPISODE_SYNC_V2_PREPARE_TIMEOUT`: max wait for every frozen participant to pause, seek, buffer, and verify stable state.
- `EPISODE_SYNC_V2_EXECUTE_TIMEOUT`: max wait for every participant to acknowledge playback execution.
- `EPISODE_SYNC_V2_STABILITY_MS`: continuous ready-state window required before `prepared`.
- `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions.
+4
View File
@@ -105,5 +105,9 @@ export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (mus
// transport grace. The target remains valid until newer room playback replaces it.
export const FORCE_SYNC_TARGET_DELAY_WARNING = FORCE_SYNC_TIMEOUT + 2000;
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
// V2 has a relay-owned loading phase. Keep this separate from the bundled
// legacy timer so rolling old/new rooms retain the legacy 60s wire contract.
export const EPISODE_SYNC_V2_LOAD_TIMEOUT = 120000;
export const EPISODE_SYNC_V2_PREPARE_TIMEOUT = 15000; // pause, seek, buffer and 1s stable verification
export const EPISODE_SYNC_V2_EXECUTE_TIMEOUT = 10000; // apply playback and acknowledge before commit
export const EPISODE_SYNC_V2_STABILITY_MS = 1000;
@@ -0,0 +1,159 @@
import { test, expect } from './helpers/extension-fixture.mjs';
async function withExtensionPage(context, extensionId, fn) {
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
try {
return await fn(page);
} finally {
await page.close();
}
}
async function selectTargetTab(context, extensionId, pageUrl) {
return withExtensionPage(context, extensionId, page => page.evaluate(async url => {
const [tab] = await chrome.tabs.query({ url });
if (!tab) throw new Error(`no tab matched ${url}`);
await chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id });
return tab.id;
}, pageUrl));
}
async function extensionMessage(context, extensionId, message) {
return withExtensionPage(context, extensionId, page => page.evaluate(
payload => chrome.runtime.sendMessage(payload),
message
));
}
async function prepareEpisodePage(page, title, currentTime) {
await page.waitForFunction(() => window.__fixtureReady === true);
await page.evaluate(async ({ title, currentTime }) => {
navigator.mediaSession.metadata = new window.MediaMetadata({ title });
const video = document.querySelector('#player');
video.muted = true;
video.playbackRate = 0.1;
video.currentTime = currentTime;
await video.play();
}, { title, currentTime });
}
async function contentLogs(context, extensionId) {
const logs = await extensionMessage(context, extensionId, { type: 'GET_LOGS' });
return logs.filter(entry => entry.message.includes('[Content]')).map(entry => entry.message);
}
test('keeps ordinary play/pause outside an episode boundary on the immediate path @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 3);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.locator('#player').evaluate(video => video.pause());
await expect.poll(async () => {
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
return history.length;
}, { timeout: 750 }).toBe(before.length + 1);
});
test('quarantines a suspicious boundary pause, then relays the final intent when no episode changes @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 9.25);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.locator('#player').evaluate(video => video.pause());
await page.waitForTimeout(500);
expect(await extensionMessage(context, extensionId, { type: 'GET_HISTORY' })).toEqual(before);
await expect.poll(async () => {
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
return history.length;
}, { timeout: 4000 }).toBe(before.length + 1);
const after = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
expect(after[0]).toMatchObject({ action: 'pause' });
});
test('relays a deliberate boundary pause immediately @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 9.25);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.locator('#player').dispatchEvent('pointerdown');
await page.locator('#player').evaluate(video => video.pause());
await expect.poll(async () => {
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
return history.length;
}, { timeout: 750 }).toBe(before.length + 1);
});
test('does not consume title-before-loadeddata or loadeddata-before-title transitions @episode-transition', async ({ context, extensionId, baseURL }) => {
const runOrdering = async ordering => {
const url = `${baseURL}/pages/simple-player.html?ordering=${ordering}-${Date.now()}`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 1);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
if (ordering === 'title-first') {
await page.evaluate(() => {
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
document.querySelector('#player').dispatchEvent(new window.Event('loadeddata'));
});
} else {
await page.evaluate(() => document.querySelector('#player').dispatchEvent(new window.Event('loadeddata')));
await page.waitForTimeout(150);
await page.evaluate(() => {
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
});
}
await expect.poll(async () => {
const logs = await contentLogs(context, extensionId);
return logs.some(line => line.includes('Episode transition detected: "Series S01E06"'));
}, { timeout: 3000 }).toBe(true);
await page.close();
await extensionMessage(context, extensionId, { type: 'CLEAR_LOGS' });
};
await runOrdering('title-first');
await runOrdering('loadeddata-first');
});
test('discards source-swap pause/play churn after the episode is confirmed @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 11);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.evaluate(async () => {
const video = document.querySelector('#player');
video.pause();
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
video.src = '../media/player-1080p-30s.mp4';
video.load();
await new Promise(resolve => video.addEventListener('loadeddata', resolve, { once: true }));
await video.play();
});
await expect.poll(async () => {
const logs = await contentLogs(context, extensionId);
return logs.some(line => line.includes('Episode transition detected: "Series S01E06"'));
}).toBe(true);
await page.waitForTimeout(2300);
expect(await extensionMessage(context, extensionId, { type: 'GET_HISTORY' })).toEqual(before);
});
+92
View File
@@ -1598,10 +1598,102 @@ test('completes Episode Sync v2 only after the packed player is stably prepared'
lobby.transactionId
);
expect(execute).toMatchObject({ phase: 'execute', transactionId: lobby.transactionId, targetTime: 0 });
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2?.executedPeers || [])
.toContain(extensionPeerId);
expect(relay.rooms.get(roomId)?.mediaState).toBeNull();
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'executed',
transactionId: lobby.transactionId
});
const complete = await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'complete',
lobby.transactionId
);
expect(complete.executedPeers).toEqual(expect.arrayContaining([coordinatorPeerId, extensionPeerId]));
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2).toBeNull();
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(3);
// A peer can fail after this packed player has already started. The
// terminal cancel must actively settle it back to paused at 0:00.
coordinator.messages.length = 0;
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'start',
expectedTitle: 'S1:E6 - Visiting Ours',
expectedEpisodeId: 'S01E06'
});
const rollbackLobby = await waitForLegacyRelayEvent(coordinator, 'episode_sync_v2');
expect(rollbackLobby.phase).toBe('lobby');
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'loaded',
transactionId: rollbackLobby.transactionId
});
const rollbackPrepare = await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'prepare',
rollbackLobby.transactionId
);
expect(rollbackPrepare.loadedPeers).toEqual(expect.arrayContaining([coordinatorPeerId, extensionPeerId]));
try {
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2?.preparedPeers || [])
.toContain(extensionPeerId);
} catch (error) {
const [logs, playerState] = await Promise.all([
getExtensionState(context, extensionId, { type: 'GET_LOGS' }).catch(() => []),
page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime,
readyState: video.readyState,
seeking: video.seeking
}))
]);
const transaction = relay.rooms.get(roomId)?.episodeSyncV2;
console.error(`Episode v2 rollback prepare diagnostics: ${JSON.stringify({
transaction: transaction ? {
transactionId: transaction.transactionId,
phase: transaction.phase,
loadedPeers: transaction.loadedPeers,
preparedPeers: transaction.preparedPeers,
deadlineAt: transaction.deadlineAt
} : null,
playerState,
logs: logs.slice(-20)
})}`);
throw error;
}
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'prepared',
transactionId: rollbackLobby.transactionId
});
await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'execute',
rollbackLobby.transactionId
);
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'failed_execute',
transactionId: rollbackLobby.transactionId,
reason: 'fixture_failure'
});
const rollbackCancel = await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'cancel',
rollbackLobby.transactionId
);
expect(rollbackCancel).toMatchObject({
reason: 'execute_failed',
settlePlaybackState: 'paused',
targetTime: 0
});
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(1);
const legacyForceFrames = coordinator.messages.filter(message => message.startsWith('42') && (() => {
try { return JSON.parse(message.substring(2))[0].startsWith('force_sync_'); } catch { return false; }
})());