mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-04 06:55:21 +00:00
fix(sync): harden episode transition compatibility
This commit is contained in:
@@ -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
|
## [v3.1.5] — 2026-08-25
|
||||||
|
|
||||||
This patch fixes room-exit cleanup and excessive popup width.
|
This patch fixes room-exit cleanup and excessive popup width.
|
||||||
|
|||||||
+49
-24
@@ -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
|
Episode lobby coordination is implemented primarily in the extension. The relay
|
||||||
tracks enough state to include `activeLobby` in `room_data` for later joiners.
|
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`
|
### `episode_lobby`
|
||||||
|
|
||||||
@@ -535,36 +538,58 @@ Clients should treat a missing or unknown capabilities list as unsupported.
|
|||||||
## Episode Sync v2
|
## Episode Sync v2
|
||||||
|
|
||||||
Relays advertise `"episode-sync-v2"`. Updated clients use the additive
|
Relays advertise `"episode-sync-v2"`. Updated clients use the additive
|
||||||
`episode_sync_v2` event without changing `PROTOCOL_VERSION`; automatic episode
|
`episode_sync_v2` event without changing `PROTOCOL_VERSION`. When the relay
|
||||||
sync is skipped safely when the relay omits the capability or any current room
|
omits the capability, or rejects START because a current peer is legacy, updated
|
||||||
peer did not announce it. Manual Force Sync remains on the legacy event family.
|
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
|
The relay creates the transaction ID, freezes non-desynced participants, owns
|
||||||
deadlines, and is the only component that advances:
|
deadlines, and is the only component that advances:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
start -> lobby/loading -> prepare -> execute
|
start -> lobby/loading -> prepare -> execute/executing -> complete
|
||||||
\-> cancel
|
| | |
|
||||||
|
+------------------+--------------+-> cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
Client phases are `start`, `loaded`, `prepared`, `failed`, and `cancel`. Relay
|
`start` carries bounded `expectedTitle` and may carry a sanitized
|
||||||
phases are `lobby`, `prepare`, `execute`, and `cancel`. Every post-start frame is
|
`expectedEpisodeId` (`S01E06` or `EP006`). Relay state includes `remainingMs`,
|
||||||
correlated by `transactionId`; duplicate or stale frames are idempotently
|
not an absolute relay wall-clock deadline. Client phases are `start`, `loaded`,
|
||||||
ignored. No participant is pre-marked loaded. `execute` is emitted exactly once
|
`prepared`, `executed`, `failed`, `failed_execute`, and `cancel`. Relay phases
|
||||||
only after every frozen participant reports `loaded`, then pauses, seeks to
|
are `lobby`, `prepare`, `execute`, `complete`, and `cancel`. Every post-start
|
||||||
0:00, reaches `readyState >= 3`, and remains on the same player/title in paused,
|
frame is correlated by `transactionId`; duplicate or stale frames are
|
||||||
non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`.
|
idempotently ignored.
|
||||||
|
|
||||||
Any timeout, failed player action, participant departure, manual media command,
|
No participant is pre-marked loaded. `execute` is emitted exactly once only
|
||||||
room/target change, or explicit cancellation produces `cancel`; v2 never
|
after every frozen participant reports `loaded`, then pauses, seeks to 0:00,
|
||||||
executes after a timeout. Peers resume only when that transaction paused a
|
reaches `readyState >= 3`, and remains on the same player/episode in paused,
|
||||||
previously playing player and no newer local or room action superseded it. A
|
non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`. The transaction remains
|
||||||
superseding room command defines the next state without racing a restoration.
|
in the relay's internal `executing` phase until every participant reports
|
||||||
Peers joining after `start` are excluded from the frozen barrier and receive no
|
`executed`. Only then does the relay commit canonical `playing` at 0:00 and emit
|
||||||
v2 frames for that transaction. Clients revalidate the complete prepared state
|
terminal `complete`.
|
||||||
again immediately before applying `execute`.
|
|
||||||
|
|
||||||
Legacy episode events remain accepted. A new relay binds their PREPARE, EXECUTE,
|
Raw `play`, `pause`, and `seek` churn is tolerated during `lobby/loading`, where
|
||||||
and CANCEL to the accepted lobby initiator, limiting duplicate old-client wire
|
players commonly replace sources. A current client can classify a fresh local
|
||||||
actions; an unmodified old non-initiator can still run its own local timeout, so
|
gesture as `episodeSyncIntent: "manual"`; that command supersedes loading before
|
||||||
full v2 guarantees require capable extensions on every peer.
|
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
@@ -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 { generateUsername } from './shared/names.js';
|
||||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.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 { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||||
import { initTabManager } from './modules/tab-manager.js';
|
import { initTabManager } from './modules/tab-manager.js';
|
||||||
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.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 episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||||
let episodeLobbyTimeout = null;
|
let episodeLobbyTimeout = null;
|
||||||
let episodeSyncV2 = null;
|
let episodeSyncV2 = null;
|
||||||
|
let episodeSyncV2PendingStart = null;
|
||||||
|
let episodeSyncV2PendingStartId = 0;
|
||||||
|
|
||||||
// --- Storage Utils ---
|
// --- Storage Utils ---
|
||||||
|
|
||||||
@@ -818,7 +820,9 @@ function emitEpisodeLobbyForCurrentPrivacy() {
|
|||||||
if (episodeLobby !== lobby
|
if (episodeLobby !== lobby
|
||||||
|| currentRoom?.roomId !== roomId
|
|| currentRoom?.roomId !== roomId
|
||||||
|| settings.roomId !== roomId) return;
|
|| settings.roomId !== roomId) return;
|
||||||
const expectedTitle = sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
const expectedTitle = toEpisodeWireTitle(
|
||||||
|
sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode)
|
||||||
|
);
|
||||||
if (expectedTitle) {
|
if (expectedTitle) {
|
||||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
|
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
|
||||||
}
|
}
|
||||||
@@ -1217,10 +1221,13 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
|
|||||||
const transactionId = typeof value.transactionId === 'string'
|
const transactionId = typeof value.transactionId === 'string'
|
||||||
? value.transactionId.substring(0, 64)
|
? value.transactionId.substring(0, 64)
|
||||||
: '';
|
: '';
|
||||||
const phase = value.phase === 'lobby' || value.phase === 'prepare' ? value.phase : '';
|
const phase = value.phase === 'lobby' || value.phase === 'prepare'
|
||||||
const expectedTitle = typeof value.expectedTitle === 'string'
|
? value.phase
|
||||||
? value.expectedTitle.substring(0, 100)
|
: (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'
|
const initiatorPeerId = typeof value.initiatorPeerId === 'string'
|
||||||
? value.initiatorPeerId.substring(0, 16)
|
? value.initiatorPeerId.substring(0, 16)
|
||||||
: '';
|
: '';
|
||||||
@@ -1238,20 +1245,117 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
|
|||||||
if (participants.length < 2
|
if (participants.length < 2
|
||||||
|| !participantSet.has(initiatorPeerId)
|
|| !participantSet.has(initiatorPeerId)
|
||||||
|| (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null;
|
|| (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 {
|
return {
|
||||||
transactionId,
|
transactionId,
|
||||||
phase,
|
phase,
|
||||||
expectedTitle,
|
expectedTitle,
|
||||||
|
expectedEpisodeId,
|
||||||
initiatorPeerId,
|
initiatorPeerId,
|
||||||
participants,
|
participants,
|
||||||
loadedPeers,
|
loadedPeers,
|
||||||
preparedPeers,
|
preparedPeers,
|
||||||
createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(),
|
createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(),
|
||||||
deadlineAt: Number.isFinite(value.deadlineAt) ? value.deadlineAt : null,
|
deadlineAt: localDeadline.deadlineAt,
|
||||||
revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1
|
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) {
|
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
|
||||||
return clearTargetSelectionForLifecycle({
|
return clearTargetSelectionForLifecycle({
|
||||||
expectedTabId,
|
expectedTabId,
|
||||||
@@ -1266,6 +1370,7 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
|
|||||||
reconnectFailed = false;
|
reconnectFailed = false;
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
reconnectStartTime = null;
|
reconnectStartTime = null;
|
||||||
|
episodeSyncV2PendingStart = null;
|
||||||
completeForceSyncBeforeTargetChange(null);
|
completeForceSyncBeforeTargetChange(null);
|
||||||
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
|
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||||
|
|
||||||
@@ -2272,7 +2377,13 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2;
|
currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2;
|
||||||
persistEpisodeSyncV2();
|
persistEpisodeSyncV2();
|
||||||
broadcastLobbyUpdate();
|
broadcastLobbyUpdate();
|
||||||
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
|
if (shouldNotifyContent) {
|
||||||
|
if (authoritativeEpisodeSyncV2.phase === 'execute') {
|
||||||
|
executeEpisodeSyncV2FromRelay(authoritativeEpisodeSyncV2).catch(() => {});
|
||||||
|
} else {
|
||||||
|
sendEpisodeSyncV2ToContent().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (episodeSyncV2) {
|
} else if (episodeSyncV2) {
|
||||||
clearEpisodeSyncV2State({ reason: 'relay_state_ended' });
|
clearEpisodeSyncV2State({ reason: 'relay_state_ended' });
|
||||||
} else {
|
} else {
|
||||||
@@ -2701,6 +2812,28 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
}
|
}
|
||||||
const phase = data.phase;
|
const phase = data.phase;
|
||||||
if (phase === 'cancel' && !data.transactionId) {
|
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');
|
addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2728,6 +2861,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
clearEpisodeSyncV2State({ reason: 'transaction_replaced' });
|
clearEpisodeSyncV2State({ reason: 'transaction_replaced' });
|
||||||
}
|
}
|
||||||
if (episodeLobby) clearEpisodeLobbyState();
|
if (episodeLobby) clearEpisodeLobbyState();
|
||||||
|
if (episodeSyncV2PendingStart
|
||||||
|
&& incoming.expectedTitle === episodeSyncV2PendingStart.expectedTitle
|
||||||
|
&& incoming.expectedEpisodeId === episodeSyncV2PendingStart.expectedEpisodeId) {
|
||||||
|
episodeSyncV2PendingStart = null;
|
||||||
|
}
|
||||||
episodeSyncV2 = incoming;
|
episodeSyncV2 = incoming;
|
||||||
if (currentRoom) currentRoom.episodeSyncV2 = incoming;
|
if (currentRoom) currentRoom.episodeSyncV2 = incoming;
|
||||||
persistEpisodeSyncV2();
|
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');
|
addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if ((phase === 'execute' || phase === 'cancel')
|
if ((phase === 'execute' || phase === 'complete' || phase === 'cancel')
|
||||||
&& episodeSyncV2
|
&& episodeSyncV2
|
||||||
&& data.transactionId === episodeSyncV2.transactionId) {
|
&& data.transactionId === episodeSyncV2.transactionId) {
|
||||||
const completed = episodeSyncV2;
|
const completed = episodeSyncV2;
|
||||||
if (phase === 'execute') {
|
if (phase === 'execute') {
|
||||||
|
await executeEpisodeSyncV2FromRelay(data);
|
||||||
|
} else if (phase === 'complete') {
|
||||||
sendMessageToCurrentContent({
|
sendMessageToCurrentContent({
|
||||||
type: 'EPISODE_SYNC_V2',
|
type: 'EPISODE_SYNC_V2',
|
||||||
transaction: { ...completed, phase: 'execute', targetTime: 0 }
|
transaction: { ...completed, phase: 'complete', targetTime: 0 }
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||||
currentRoom.peers.forEach(candidate => {
|
currentRoom.peers.forEach(candidate => {
|
||||||
@@ -2758,7 +2898,10 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' });
|
clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' });
|
||||||
addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success');
|
addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success');
|
||||||
} else {
|
} else {
|
||||||
clearEpisodeSyncV2State({ reason: data.reason || 'cancelled' });
|
clearEpisodeSyncV2State({
|
||||||
|
reason: data.reason || 'cancelled',
|
||||||
|
relayState: data
|
||||||
|
});
|
||||||
addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn');
|
addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2928,6 +3071,7 @@ function completeForceSyncBeforeTargetChange(nextTabId) {
|
|||||||
const normalizedNextTabId = normalizeTabId(nextTabId);
|
const normalizedNextTabId = normalizeTabId(nextTabId);
|
||||||
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
|
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
|
||||||
|
|
||||||
|
episodeSyncV2PendingStart = null;
|
||||||
if (episodeSyncV2) cancelEpisodeSyncV2('target_changed');
|
if (episodeSyncV2) cancelEpisodeSyncV2('target_changed');
|
||||||
if (!isForceSyncInitiator) return;
|
if (!isForceSyncInitiator) return;
|
||||||
|
|
||||||
@@ -2945,7 +3089,7 @@ function episodeLobbyForUi() {
|
|||||||
return {
|
return {
|
||||||
expectedTitle: episodeSyncV2.expectedTitle,
|
expectedTitle: episodeSyncV2.expectedTitle,
|
||||||
initiatorPeerId: episodeSyncV2.initiatorPeerId,
|
initiatorPeerId: episodeSyncV2.initiatorPeerId,
|
||||||
readyPeers: episodeSyncV2.phase === 'prepare'
|
readyPeers: episodeSyncV2.phase === 'prepare' || episodeSyncV2.phase === 'execute'
|
||||||
? [...episodeSyncV2.preparedPeers]
|
? [...episodeSyncV2.preparedPeers]
|
||||||
: [...episodeSyncV2.loadedPeers],
|
: [...episodeSyncV2.loadedPeers],
|
||||||
createdAt: episodeSyncV2.createdAt,
|
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;
|
const previous = episodeSyncV2;
|
||||||
episodeSyncV2 = null;
|
episodeSyncV2 = null;
|
||||||
if (currentRoom) currentRoom.episodeSyncV2 = null;
|
if (currentRoom) currentRoom.episodeSyncV2 = null;
|
||||||
@@ -2983,6 +3180,7 @@ function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' }
|
|||||||
type: 'EPISODE_SYNC_V2',
|
type: 'EPISODE_SYNC_V2',
|
||||||
transaction: {
|
transaction: {
|
||||||
...previous,
|
...previous,
|
||||||
|
...(relayState && typeof relayState === 'object' ? relayState : {}),
|
||||||
phase: 'cancel',
|
phase: 'cancel',
|
||||||
reason
|
reason
|
||||||
}
|
}
|
||||||
@@ -5623,7 +5821,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
|
|
||||||
const newTitle = message.payload && message.payload.newTitle;
|
const newTitle = message.payload && message.payload.newTitle;
|
||||||
if (newTitle && extractEpisodeId(newTitle) === null) {
|
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' });
|
sendResponse({ status: 'not_an_episode' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5637,12 +5835,14 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
sendResponse({ status: 'ignored_stale_session' });
|
sendResponse({ status: 'ignored_stale_session' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
|
const sharedLobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
|
||||||
if (!lobbyTitle) {
|
const episodeIdentity = createEpisodeWireIdentity(sharedLobbyTitle);
|
||||||
|
if (!episodeIdentity) {
|
||||||
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
|
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
|
||||||
sendResponse({ status: 'title_privacy_no_lobby' });
|
sendResponse({ status: 'title_privacy_no_lobby' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const lobbyTitle = episodeIdentity.expectedTitle;
|
||||||
|
|
||||||
// Check setting
|
// Check setting
|
||||||
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
|
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
|
||||||
@@ -5680,21 +5880,29 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatic episode sync v2 is relay-owned. Never self-pause or fall
|
// Prefer the relay-owned barrier. Old relays and mixed rooms retain the
|
||||||
// back to the legacy client-owned lobby: on an old/mixed relay that path
|
// established legacy lobby, but only from this exact room/target/start
|
||||||
// can create multiple Force Sync initiators. Manual Force Sync remains
|
// context so a delayed rejection cannot resurrect stale automation.
|
||||||
// available and unchanged.
|
|
||||||
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
|
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
|
||||||
addLog(`Episode change ("${lobbyTitle}") — relay lacks Episode Sync v2; automatic sync skipped safely.`, 'warn');
|
const pending = createPendingEpisodeSyncV2Start(episodeIdentity, sender);
|
||||||
sendResponse({ status: 'episode_sync_v2_unsupported' });
|
episodeSyncV2PendingStart = pending;
|
||||||
|
const fallbackStatus = startLegacyEpisodeLobbyForTransition(episodeIdentity, pending);
|
||||||
|
episodeSyncV2PendingStart = null;
|
||||||
|
addLog(`Episode change ("${lobbyTitle}") — legacy relay fallback: ${fallbackStatus}.`, 'warn');
|
||||||
|
sendResponse({ status: fallbackStatus });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (episodeSyncV2 && sameEpisode(episodeSyncV2.expectedTitle, lobbyTitle)) {
|
if (episodeSyncV2
|
||||||
|
&& episodeSyncV2.expectedTitle === episodeIdentity.expectedTitle
|
||||||
|
&& episodeSyncV2.expectedEpisodeId === episodeIdentity.expectedEpisodeId) {
|
||||||
sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId });
|
sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (episodeSyncV2) cancelEpisodeSyncV2('new_episode');
|
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');
|
addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn');
|
||||||
sendResponse({ status: 'episode_sync_v2_offline' });
|
sendResponse({ status: 'episode_sync_v2_offline' });
|
||||||
return;
|
return;
|
||||||
@@ -5725,7 +5933,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const readyTitle = sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode);
|
const readyTitle = toEpisodeWireTitle(
|
||||||
|
sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode)
|
||||||
|
);
|
||||||
lobby.readyPeers.push(peerId);
|
lobby.readyPeers.push(peerId);
|
||||||
persistEpisodeLobby();
|
persistEpisodeLobby();
|
||||||
broadcastLobbyUpdate();
|
broadcastLobbyUpdate();
|
||||||
@@ -5758,7 +5968,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
const localTitle = message.payload?.title;
|
const localTitle = message.payload?.title;
|
||||||
if (localPhase !== 'failed'
|
if (localPhase !== 'failed'
|
||||||
&& (typeof localTitle !== 'string' || !sameEpisodeStrict(localTitle, transaction.expectedTitle))) {
|
&& (typeof localTitle !== 'string' || !sameEpisodeIdentity(
|
||||||
|
localTitle,
|
||||||
|
transaction.expectedTitle,
|
||||||
|
transaction.expectedEpisodeId
|
||||||
|
))) {
|
||||||
sendResponse({ status: 'ignored_episode_mismatch' });
|
sendResponse({ status: 'ignored_episode_mismatch' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+474
-47
@@ -331,8 +331,17 @@
|
|||||||
let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush
|
let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush
|
||||||
|
|
||||||
// --- Episode Auto-Sync State ---
|
// --- 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 lastKnownMediaTitle = null;
|
||||||
let episodeTransitionDebounce = 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 _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||||
let lobbyPollTimer = null;
|
let lobbyPollTimer = null;
|
||||||
let episodeSyncV2State = null;
|
let episodeSyncV2State = null;
|
||||||
@@ -354,6 +363,10 @@
|
|||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
if (area === 'local' && changes.autoSyncNextEpisode) {
|
if (area === 'local' && changes.autoSyncNextEpisode) {
|
||||||
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
|
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
|
||||||
|
if (!_autoSyncEnabled) {
|
||||||
|
clearEpisodeTransitionCandidate({ commitCurrentTitle: true });
|
||||||
|
flushEpisodeTransitionQuarantine();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (area === 'local' && changes.audioSettings) {
|
if (area === 'local' && changes.audioSettings) {
|
||||||
_audioSettings = mergeAudioSettings(changes.audioSettings.newValue);
|
_audioSettings = mergeAudioSettings(changes.audioSettings.newValue);
|
||||||
@@ -1090,6 +1103,13 @@
|
|||||||
// Returns null if no episode pattern found.
|
// Returns null if no episode pattern found.
|
||||||
// --- SHARED_EPISODE_UTILS_INJECT_START ---
|
// --- SHARED_EPISODE_UTILS_INJECT_START ---
|
||||||
// This block is automatically replaced by /scripts/build-extension.cjs
|
// 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) {
|
function extractEpisodeId(title) {
|
||||||
if (!title || typeof title !== 'string') return null;
|
if (!title || typeof title !== 'string') return null;
|
||||||
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
|
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
|
// 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.
|
// S/E-only titles still fall back to the canonical episode ID.
|
||||||
function sameEpisodeStrict(titleA, titleB) {
|
function sameEpisodeStrict(titleA, titleB) {
|
||||||
if (!sameEpisode(titleA, titleB)) return false;
|
const wireTitleA = toEpisodeWireTitle(titleA);
|
||||||
const contextA = episodeContext(titleA);
|
const wireTitleB = toEpisodeWireTitle(titleB);
|
||||||
const contextB = episodeContext(titleB);
|
if (!sameEpisode(wireTitleA, wireTitleB)) return false;
|
||||||
return !contextA || !contextB || contextA === contextB;
|
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 ---
|
// --- SHARED_EPISODE_UTILS_INJECT_END ---
|
||||||
|
|
||||||
@@ -1142,26 +1176,177 @@
|
|||||||
if (!idA || !idB) return false; // At least one unparseable → allow
|
if (!idA || !idB) return false; // At least one unparseable → allow
|
||||||
return idA !== idB; // Both parseable → only block if different
|
return idA !== idB; // Both parseable → only block if different
|
||||||
}
|
}
|
||||||
function checkEpisodeTransition() {
|
function getEpisodeSource(video) {
|
||||||
|
return video ? (video.currentSrc || video.src || null) : null;
|
||||||
const currentTitle = getMediaTitle();
|
|
||||||
|
|
||||||
const video = findVideo();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always track the latest known title
|
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 = getSyncCurrentTime(video);
|
||||||
|
const source = getEpisodeSource(video);
|
||||||
|
if (!lastKnownMediaTitle) {
|
||||||
if (currentTitle) lastKnownMediaTitle = currentTitle;
|
if (currentTitle) lastKnownMediaTitle = currentTitle;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function onEpisodeTransition(newTitle) {
|
||||||
@@ -1304,7 +1489,7 @@
|
|||||||
&& video
|
&& video
|
||||||
&& video === findVideo()
|
&& video === findVideo()
|
||||||
&& video.isConnected !== false
|
&& video.isConnected !== false
|
||||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
|
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
|
||||||
&& video.paused;
|
&& video.paused;
|
||||||
if (mayResume) {
|
if (mayResume) {
|
||||||
const resumed = await tryMediaAction(EVENTS.PLAY);
|
const resumed = await tryMediaAction(EVENTS.PLAY);
|
||||||
@@ -1318,8 +1503,8 @@
|
|||||||
const title = getMediaTitle();
|
const title = getMediaTitle();
|
||||||
const matches = video
|
const matches = video
|
||||||
&& title
|
&& title
|
||||||
&& sameEpisodeStrict(title, state.expectedTitle)
|
&& sameEpisodeIdentity(title, state.expectedTitle, state.expectedEpisodeId)
|
||||||
&& video.readyState >= 1
|
&& video.readyState >= 3
|
||||||
&& getSyncCurrentTime(video) !== null;
|
&& getSyncCurrentTime(video) !== null;
|
||||||
if (!matches) {
|
if (!matches) {
|
||||||
state.loadCandidateVideo = null;
|
state.loadCandidateVideo = null;
|
||||||
@@ -1339,8 +1524,19 @@
|
|||||||
|
|
||||||
function startEpisodeSyncV2Lobby(transaction) {
|
function startEpisodeSyncV2Lobby(transaction) {
|
||||||
if (!transaction?.transactionId || !transaction.expectedTitle) return;
|
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) {
|
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||||
episodeSyncV2State.phase = 'lobby';
|
episodeSyncV2State.phase = 'lobby';
|
||||||
|
episodeSyncV2State.expectedEpisodeId = typeof transaction.expectedEpisodeId === 'string'
|
||||||
|
? transaction.expectedEpisodeId
|
||||||
|
: episodeSyncV2State.expectedEpisodeId;
|
||||||
episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt)
|
episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt)
|
||||||
? transaction.deadlineAt
|
? transaction.deadlineAt
|
||||||
: episodeSyncV2State.deadlineAt;
|
: episodeSyncV2State.deadlineAt;
|
||||||
@@ -1351,6 +1547,9 @@
|
|||||||
episodeSyncV2State = {
|
episodeSyncV2State = {
|
||||||
transactionId: transaction.transactionId,
|
transactionId: transaction.transactionId,
|
||||||
expectedTitle: transaction.expectedTitle,
|
expectedTitle: transaction.expectedTitle,
|
||||||
|
expectedEpisodeId: typeof transaction.expectedEpisodeId === 'string'
|
||||||
|
? transaction.expectedEpisodeId
|
||||||
|
: null,
|
||||||
phase: 'lobby',
|
phase: 'lobby',
|
||||||
generation: episodeSyncV2Generation,
|
generation: episodeSyncV2Generation,
|
||||||
loadedReported: false,
|
loadedReported: false,
|
||||||
@@ -1365,7 +1564,9 @@
|
|||||||
pausedByTransaction: false,
|
pausedByTransaction: false,
|
||||||
manualAction: false,
|
manualAction: false,
|
||||||
programmaticPausePending: false,
|
programmaticPausePending: false,
|
||||||
prepareStarted: false
|
prepareStarted: false,
|
||||||
|
executePromise: null,
|
||||||
|
executeResult: null
|
||||||
};
|
};
|
||||||
stopEpisodeSyncV2Poll();
|
stopEpisodeSyncV2Poll();
|
||||||
checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||||
@@ -1384,7 +1585,7 @@
|
|||||||
&& state.phase === 'prepare'
|
&& state.phase === 'prepare'
|
||||||
&& video === findVideo()
|
&& video === findVideo()
|
||||||
&& video.isConnected !== false
|
&& video.isConnected !== false
|
||||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
|
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
|
||||||
&& video.paused
|
&& video.paused
|
||||||
&& !video.seeking
|
&& !video.seeking
|
||||||
&& video.readyState >= 3
|
&& video.readyState >= 3
|
||||||
@@ -1417,6 +1618,9 @@
|
|||||||
}
|
}
|
||||||
const state = episodeSyncV2State;
|
const state = episodeSyncV2State;
|
||||||
if (!state || state.transactionId !== transaction.transactionId) return;
|
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;
|
state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt;
|
||||||
if (state.prepareStarted) return;
|
if (state.prepareStarted) return;
|
||||||
stopEpisodeSyncV2Poll();
|
stopEpisodeSyncV2Poll();
|
||||||
@@ -1424,7 +1628,8 @@
|
|||||||
state.prepareStarted = true;
|
state.prepareStarted = true;
|
||||||
const video = findVideo();
|
const video = findVideo();
|
||||||
const currentTitle = getMediaTitle();
|
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')) {
|
if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) {
|
||||||
retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch');
|
retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch');
|
||||||
}
|
}
|
||||||
@@ -1479,6 +1684,56 @@
|
|||||||
clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {});
|
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() {
|
function getPlayerActionFixes() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1980,36 +2235,51 @@
|
|||||||
reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn');
|
reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn');
|
||||||
});
|
});
|
||||||
} else if (transaction.phase === 'execute') {
|
} else if (transaction.phase === 'execute') {
|
||||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
|
||||||
const state = episodeSyncV2State;
|
const state = episodeSyncV2State;
|
||||||
const video = state.video || findVideo();
|
const video = state?.video || findVideo();
|
||||||
const current = video ? getSyncCurrentTime(video) : null;
|
const current = video ? getSyncCurrentTime(video) : null;
|
||||||
const canExecute = video
|
const canExecute = state
|
||||||
|
&& state.transactionId === transaction.transactionId
|
||||||
&& state.phase === 'prepare'
|
&& state.phase === 'prepare'
|
||||||
&& video === findVideo()
|
&& video === findVideo()
|
||||||
&& video.isConnected !== false
|
&& video.isConnected !== false
|
||||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
|
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
|
||||||
&& video.paused
|
&& video.paused
|
||||||
&& !video.seeking
|
&& !video.seeking
|
||||||
&& video.readyState >= 3
|
&& video.readyState >= 3
|
||||||
&& current !== null
|
&& current !== null
|
||||||
&& Math.abs(current) < 1;
|
&& Math.abs(current) < 1;
|
||||||
clearEpisodeSyncV2Content({ resume: false }).catch(() => {});
|
if (!canExecute) {
|
||||||
if (canExecute) {
|
sendResponse({ status: 'failed_execute', reason: 'prepared_state_changed' });
|
||||||
Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => {
|
return true;
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
executeEpisodeSyncV2(transaction).then(sendResponse).catch(() => {
|
||||||
|
sendResponse({ status: 'failed_execute', reason: 'unexpected_error' });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else if (transaction.phase === 'complete') {
|
||||||
|
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||||
|
clearEpisodeSyncV2Content({ resume: false }).catch(() => {});
|
||||||
}
|
}
|
||||||
} else if (transaction.phase === 'cancel') {
|
} else if (transaction.phase === 'cancel') {
|
||||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||||
|
const settlePaused = transaction.settlePlaybackState === 'paused';
|
||||||
// A superseding room command follows this cancellation on
|
// A superseding room command follows this cancellation on
|
||||||
// the same ordered socket. Do not race it with restoration
|
// the same ordered socket. Do not race it with restoration
|
||||||
// of the pre-transaction play state.
|
// 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' });
|
sendResponse({ status: 'ok' });
|
||||||
@@ -2215,6 +2485,10 @@
|
|||||||
|
|
||||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||||
|
|
||||||
|
const episodeSyncIntent = episodeSyncV2State?.phase === 'lobby'
|
||||||
|
&& hcmClassifyIntent() === 'deliberate'
|
||||||
|
? 'manual'
|
||||||
|
: undefined;
|
||||||
runtimeMessage({
|
runtimeMessage({
|
||||||
type: 'CONTENT_EVENT',
|
type: 'CONTENT_EVENT',
|
||||||
action,
|
action,
|
||||||
@@ -2222,7 +2496,8 @@
|
|||||||
currentTime: current,
|
currentTime: current,
|
||||||
targetTime: current,
|
targetTime: current,
|
||||||
mediaTitle: mediaTitle,
|
mediaTitle: mediaTitle,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
|
episodeSyncIntent
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
@@ -2230,6 +2505,114 @@
|
|||||||
scheduleProactiveHeartbeat();
|
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
|
// 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
|
// 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
|
// 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.
|
// Play/Pause pass through — user may want to immediately pause after tabbing back.
|
||||||
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
|
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
|
// Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The
|
||||||
// synchronous gates above have already run; only the network emit is
|
// synchronous gates above have already run; only the network emit is
|
||||||
// governed here. Leading edge sends the first event instantly; further
|
// governed here. Leading edge sends the first event instantly; further
|
||||||
@@ -2493,9 +2883,31 @@
|
|||||||
|
|
||||||
let lastVideoSrc = undefined;
|
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 => {
|
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) {
|
function detachVideoListeners(video) {
|
||||||
@@ -2507,6 +2919,9 @@
|
|||||||
if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking);
|
if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking);
|
||||||
video.removeEventListener('seeked', handlers.seeked);
|
video.removeEventListener('seeked', handlers.seeked);
|
||||||
video.removeEventListener('loadeddata', handlers.loadeddata);
|
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);
|
if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting);
|
||||||
delete video._koalaHandlers;
|
delete video._koalaHandlers;
|
||||||
}
|
}
|
||||||
@@ -2517,14 +2932,18 @@
|
|||||||
|
|
||||||
function cancelPendingVideoEvents() {
|
function cancelPendingVideoEvents() {
|
||||||
if (seekDebounceTimer) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; }
|
if (seekDebounceTimer) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; }
|
||||||
if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; }
|
cancelPlayPauseCoalesce();
|
||||||
pendingPlayPauseAction = null;
|
discardEpisodeTransitionQuarantine();
|
||||||
pendingPlayPauseVideo = null;
|
clearEpisodeTransitionCandidate();
|
||||||
|
episodeTransitionNoiseVideo = null;
|
||||||
|
episodeTransitionNoiseUntil = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupListeners() {
|
function setupListeners() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
const video = findVideo();
|
const video = findVideo();
|
||||||
|
const previousVideo = activeVideo;
|
||||||
|
const playerChanged = !!previousVideo && previousVideo !== video;
|
||||||
if (activeVideo !== video) cancelPendingVideoEvents();
|
if (activeVideo !== video) cancelPendingVideoEvents();
|
||||||
for (const attached of [...attachedVideos]) {
|
for (const attached of [...attachedVideos]) {
|
||||||
if (attached !== video) detachVideoListeners(attached);
|
if (attached !== video) detachVideoListeners(attached);
|
||||||
@@ -2543,6 +2962,9 @@
|
|||||||
seeking: handleSeeking,
|
seeking: handleSeeking,
|
||||||
seeked: handleSeeked,
|
seeked: handleSeeked,
|
||||||
loadeddata: handleLoadedData,
|
loadeddata: handleLoadedData,
|
||||||
|
loadstart: handleLoadStart,
|
||||||
|
emptied: handleEmptied,
|
||||||
|
ended: handleEnded,
|
||||||
waiting: handleWaiting
|
waiting: handleWaiting
|
||||||
};
|
};
|
||||||
video.addEventListener('play', handlePlay);
|
video.addEventListener('play', handlePlay);
|
||||||
@@ -2550,9 +2972,13 @@
|
|||||||
video.addEventListener('seeking', handleSeeking);
|
video.addEventListener('seeking', handleSeeking);
|
||||||
video.addEventListener('seeked', handleSeeked);
|
video.addEventListener('seeked', handleSeeked);
|
||||||
video.addEventListener('loadeddata', handleLoadedData);
|
video.addEventListener('loadeddata', handleLoadedData);
|
||||||
|
video.addEventListener('loadstart', handleLoadStart);
|
||||||
|
video.addEventListener('emptied', handleEmptied);
|
||||||
|
video.addEventListener('ended', handleEnded);
|
||||||
video.addEventListener('waiting', handleWaiting);
|
video.addEventListener('waiting', handleWaiting);
|
||||||
attachedVideos.add(video);
|
attachedVideos.add(video);
|
||||||
video.dataset.koalaAttached = 'true';
|
video.dataset.koalaAttached = 'true';
|
||||||
|
if (playerChanged) checkEpisodeTransition('player_changed');
|
||||||
lastVideoSrc = video.currentSrc || video.src || null;
|
lastVideoSrc = video.currentSrc || video.src || null;
|
||||||
|
|
||||||
if (!lastKnownMediaTitle) {
|
if (!lastKnownMediaTitle) {
|
||||||
@@ -2594,7 +3020,8 @@
|
|||||||
|
|
||||||
if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
|
if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
|
||||||
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
|
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
|
||||||
checkEpisodeTransition();
|
cancelPlayPauseCoalesce();
|
||||||
|
checkEpisodeTransition('source_changed');
|
||||||
}
|
}
|
||||||
setupListeners();
|
setupListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { describe, expect, it } from 'vitest';
|
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 extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
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');
|
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');
|
expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2');
|
||||||
const episodeChanged = between(
|
const episodeChanged = between(
|
||||||
backgroundSource,
|
backgroundSource,
|
||||||
@@ -34,9 +42,28 @@ describe('Episode Sync v2 extension contract', () => {
|
|||||||
"message.type === 'EPISODE_READY_LOCAL'"
|
"message.type === 'EPISODE_READY_LOCAL'"
|
||||||
);
|
);
|
||||||
expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)');
|
expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)');
|
||||||
expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start'");
|
expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', ...episodeIdentity })");
|
||||||
expect(episodeChanged).not.toContain('PAUSE_FOR_LOBBY');
|
expect(episodeChanged).toContain('createPendingEpisodeSyncV2Start(episodeIdentity, sender)');
|
||||||
expect(episodeChanged).not.toContain('emit(EVENTS.EPISODE_LOBBY');
|
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', () => {
|
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('message.transactionId !== transaction.transactionId');
|
||||||
expect(handler).toContain('transaction.phase !== expectedLocalPhase');
|
expect(handler).toContain('transaction.phase !== expectedLocalPhase');
|
||||||
expect(handler).toContain("!isCurrentContentSender(sender)");
|
expect(handler).toContain("!isCurrentContentSender(sender)");
|
||||||
expect(handler).toContain('!sameEpisodeStrict(localTitle, transaction.expectedTitle)');
|
expect(handler).toContain('!sameEpisodeIdentity(');
|
||||||
expect(handler).toContain('emitLive(EVENTS.EPISODE_SYNC_V2');
|
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', () => {
|
it('requires the same player and episode to remain paused, seeked, buffered and stable', () => {
|
||||||
const stable = between(
|
const stable = between(
|
||||||
contentSource,
|
contentSource,
|
||||||
@@ -59,7 +102,7 @@ describe('Episode Sync v2 extension contract', () => {
|
|||||||
'async function prepareEpisodeSyncV2('
|
'async function prepareEpisodeSyncV2('
|
||||||
);
|
);
|
||||||
expect(stable).toContain('video === findVideo()');
|
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.paused');
|
||||||
expect(stable).toContain('!video.seeking');
|
expect(stable).toContain('!video.seeking');
|
||||||
expect(stable).toContain('video.readyState >= 3');
|
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.wasPlayingBeforePrepare');
|
||||||
expect(clear).toContain('!state.manualAction');
|
expect(clear).toContain('!state.manualAction');
|
||||||
expect(clear).toContain('video === findVideo()');
|
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)');
|
expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,14 +147,40 @@ describe('Episode Sync v2 extension contract', () => {
|
|||||||
"message.type === 'EPISODE_SYNC_V2'",
|
"message.type === 'EPISODE_SYNC_V2'",
|
||||||
'// Episode Auto-Sync: Legacy lobby notification from background'
|
'// 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', () => {
|
it('revalidates the complete prepared state immediately before execute', () => {
|
||||||
const handler = between(
|
const handler = between(
|
||||||
contentSource,
|
contentSource,
|
||||||
"transaction.phase === 'execute'",
|
'function executeEpisodeSyncV2(',
|
||||||
"transaction.phase === 'cancel'"
|
'function getPlayerActionFixes('
|
||||||
);
|
);
|
||||||
expect(handler).toContain("state.phase === 'prepare'");
|
expect(handler).toContain("state.phase === 'prepare'");
|
||||||
expect(handler).toContain('video === findVideo()');
|
expect(handler).toContain('video === findVideo()');
|
||||||
@@ -122,8 +191,30 @@ describe('Episode Sync v2 extension contract', () => {
|
|||||||
expect(handler).toContain('Math.abs(current) < 1');
|
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', () => {
|
it('injects the shared stability window into packaged content scripts', () => {
|
||||||
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
|
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
|
||||||
expect(buildSource).toContain('episodeSyncStabilityVal');
|
expect(buildSource).toContain('episodeSyncStabilityVal');
|
||||||
|
expect(buildSource).toContain(".replace(/export const /g, 'const ')");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
* Keep in sync with the injection block in content.js!
|
* 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) {
|
export function extractEpisodeId(title) {
|
||||||
if (!title || typeof title !== 'string') return null;
|
if (!title || typeof title !== 'string') return null;
|
||||||
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
|
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
|
// 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.
|
// S/E-only titles still fall back to the canonical episode ID.
|
||||||
export function sameEpisodeStrict(titleA, titleB) {
|
export function sameEpisodeStrict(titleA, titleB) {
|
||||||
if (!sameEpisode(titleA, titleB)) return false;
|
const wireTitleA = toEpisodeWireTitle(titleA);
|
||||||
const contextA = episodeContext(titleA);
|
const wireTitleB = toEpisodeWireTitle(titleB);
|
||||||
const contextB = episodeContext(titleB);
|
if (!sameEpisode(wireTitleA, wireTitleB)) return false;
|
||||||
return !contextA || !contextB || contextA === contextB;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('episode title matching', () => {
|
||||||
it.each([
|
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', 'S01E06 - Visiting Ours')).toBe(true);
|
||||||
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S1:E6 - Another Show')).toBe(false);
|
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S1:E6 - Another Show')).toBe(false);
|
||||||
expect(sameEpisodeStrict('S1:E6', 'S01E06 - Visiting Ours')).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
|||||||
const stripped = euContent
|
const stripped = euContent
|
||||||
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
|
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
|
||||||
.replace(/export function /g, 'function ')
|
.replace(/export function /g, 'function ')
|
||||||
|
.replace(/export const /g, 'const ')
|
||||||
.trim();
|
.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}`;
|
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');
|
content = replaceRequiredBlock(content, euPattern, euRep, 'Episode utils injection');
|
||||||
|
|||||||
+196
-47
@@ -10,7 +10,7 @@ import {
|
|||||||
materializeMediaIntent,
|
materializeMediaIntent,
|
||||||
reserveLatestMediaIntentSequence
|
reserveLatestMediaIntentSequence
|
||||||
} from '../extension/offline-media-intent.js';
|
} 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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
|
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');
|
assert.equal(event,'room_data');
|
||||||
return 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));
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
|
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
|
// 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);
|
await j(episodeB, episodeRid, 'episode-b', null, episodeCaps);
|
||||||
episodeA._m.length = episodeB._m.length = 0;
|
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 episodeLobbyA = await w(episodeA, 'episode_sync_v2');
|
||||||
const episodeLobbyB = await w(episodeB, 'episode_sync_v2');
|
const episodeLobbyB = await w(episodeB, 'episode_sync_v2');
|
||||||
assert.equal(episodeLobbyA.phase, 'lobby');
|
assert.equal(episodeLobbyA.phase, 'lobby');
|
||||||
assert.equal(episodeLobbyA.transactionId, episodeLobbyB.transactionId);
|
assert.equal(episodeLobbyA.transactionId, episodeLobbyB.transactionId);
|
||||||
assert.deepEqual(episodeLobbyA.participants, ['episode-a', 'episode-b']);
|
assert.deepEqual(episodeLobbyA.participants, ['episode-a', 'episode-b']);
|
||||||
assert.deepEqual(episodeLobbyA.loadedPeers, [], 'initiator is not pre-marked loaded');
|
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;
|
const episodeTxId = episodeLobbyA.transactionId;
|
||||||
|
|
||||||
s(episodeB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S99E99' });
|
s(episodeB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S99E99' });
|
||||||
@@ -262,12 +288,47 @@ try {
|
|||||||
const executeB = await w(episodeB, 'episode_sync_v2');
|
const executeB = await w(episodeB, 'episode_sync_v2');
|
||||||
assert.equal(executeA.phase, 'execute');
|
assert.equal(executeA.phase, 'execute');
|
||||||
assert.equal(executeB.phase, 'execute');
|
assert.equal(executeB.phase, 'execute');
|
||||||
assert.equal(mod.rooms.get(episodeRid).episodeSyncV2, null);
|
assert.equal(mod.rooms.get(episodeRid).episodeSyncV2.phase, 'executing');
|
||||||
assert.equal(mod.rooms.get(episodeRid).mediaState.playbackState, 'playing');
|
assert.equal(mod.rooms.get(episodeRid).mediaState, null,
|
||||||
assert.equal(mod.rooms.get(episodeRid).mediaState.currentTime, 0);
|
'canonical playing is not committed before every execute ACK');
|
||||||
let duplicateExecute = false;
|
let duplicateExecute = false;
|
||||||
try { await w(episodeB, 'episode_sync_v2', 300); duplicateExecute = true; } catch { /* expected */ }
|
try { await w(episodeB, 'episode_sync_v2', 300); duplicateExecute = true; } catch { /* expected */ }
|
||||||
assert.equal(duplicateExecute, false, 'execute is emitted exactly once');
|
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();
|
close();
|
||||||
resetConnectionRate();
|
resetConnectionRate();
|
||||||
|
|
||||||
@@ -311,25 +372,81 @@ try {
|
|||||||
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02' });
|
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02' });
|
||||||
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
|
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
|
||||||
s(episodeAbortA, 'play', { currentTime: 2, seq: 1 });
|
s(episodeAbortA, 'play', { currentTime: 2, seq: 1 });
|
||||||
const manualCancelA = await w(episodeAbortA, 'episode_sync_v2');
|
const loadingPlayB = await w(episodeAbortB, 'play');
|
||||||
const manualCancelB = await w(episodeAbortB, 'episode_sync_v2');
|
assert.equal(loadingPlayB.currentTime, 2);
|
||||||
const manualPlayB = await w(episodeAbortB, 'play');
|
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2.phase, 'loading',
|
||||||
assert.equal(manualCancelA.reason, 'superseded');
|
'raw source-swap playback does not cancel the loading barrier');
|
||||||
assert.equal(manualCancelB.reason, 'superseded');
|
s(episodeAbortA, 'episode_sync_v2', {
|
||||||
assert.equal(manualPlayB.currentTime, 2);
|
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);
|
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
|
||||||
|
|
||||||
const abortRoom = mod.rooms.get(episodeAbortRid);
|
const abortRoom = mod.rooms.get(episodeAbortRid);
|
||||||
abortRoom.controlMode = 'host-only';
|
abortRoom.controlMode = 'host-only';
|
||||||
abortRoom.controllers = new Set(['abort-a']);
|
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');
|
const guestStartCancel = await w(episodeAbortB, 'episode_sync_v2');
|
||||||
assert.equal(guestStartCancel.reason, 'not_controller');
|
assert.equal(guestStartCancel.reason, 'not_controller');
|
||||||
assert.equal(abortRoom.episodeSyncV2, null);
|
assert.equal(abortRoom.episodeSyncV2, null);
|
||||||
close();
|
close();
|
||||||
resetConnectionRate();
|
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 frozenRid = 'episode-frozen-'+Date.now();
|
||||||
const frozenA = await c(), frozenB = await c();
|
const frozenA = await c(), frozenB = await c();
|
||||||
await j(frozenA, frozenRid, 'frozen-a', null, episodeCaps);
|
await j(frozenA, frozenRid, 'frozen-a', null, episodeCaps);
|
||||||
@@ -342,20 +459,26 @@ try {
|
|||||||
const frozenLateRoom = await j(frozenLate, frozenRid, 'frozen-late');
|
const frozenLateRoom = await j(frozenLate, frozenRid, 'frozen-late');
|
||||||
assert.equal(frozenLateRoom.episodeSyncV2, null);
|
assert.equal(frozenLateRoom.episodeSyncV2, null);
|
||||||
assert.deepEqual(frozenLobbyA.participants, ['frozen-a', 'frozen-b']);
|
assert.deepEqual(frozenLobbyA.participants, ['frozen-a', 'frozen-b']);
|
||||||
frozenLate._m.length = 0;
|
const lateCancelA = await w(frozenA, 'episode_sync_v2');
|
||||||
for (const peer of [frozenA, frozenB]) {
|
const lateCancelB = await w(frozenB, 'episode_sync_v2');
|
||||||
s(peer, 'episode_sync_v2', { phase: 'loaded', transactionId: frozenLobbyA.transactionId });
|
assert.equal(lateCancelA.reason, 'membership_changed');
|
||||||
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); }
|
assert.equal(lateCancelB.reason, 'membership_changed');
|
||||||
}
|
assert.equal(mod.rooms.get(frozenRid).episodeSyncV2, null);
|
||||||
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2');
|
|
||||||
for (const peer of [frozenA, frozenB]) {
|
s(frozenLate, 'leave_room', {});
|
||||||
s(peer, 'episode_sync_v2', { phase: 'prepared', transactionId: frozenLobbyA.transactionId });
|
await w(frozenA, 'peer_status');
|
||||||
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); }
|
await w(frozenB, 'peer_status');
|
||||||
}
|
frozenA._m.length = frozenB._m.length = 0;
|
||||||
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2');
|
s(frozenA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E08' });
|
||||||
let lateReceivedV2 = false;
|
await w(frozenA, 'episode_sync_v2');
|
||||||
try { await w(frozenLate, 'episode_sync_v2', 300); lateReceivedV2 = true; } catch { /* expected */ }
|
await w(frozenB, 'episode_sync_v2');
|
||||||
assert.equal(lateReceivedV2, false);
|
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();
|
close();
|
||||||
resetConnectionRate();
|
resetConnectionRate();
|
||||||
|
|
||||||
@@ -377,7 +500,32 @@ try {
|
|||||||
assert.equal(timeoutCancelB.phase, 'cancel');
|
assert.equal(timeoutCancelB.phase, 'cancel');
|
||||||
assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null);
|
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');
|
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');
|
assert.notEqual(cancelLobby.transactionId, departureLobby.transactionId, 'new transaction receives a fresh identity');
|
||||||
s(cancelB, 'leave_room', {});
|
s(cancelB, 'leave_room', {});
|
||||||
@@ -389,27 +537,22 @@ try {
|
|||||||
close();
|
close();
|
||||||
resetConnectionRate();
|
resetConnectionRate();
|
||||||
|
|
||||||
// New relay hardening for old extensions: only the accepted lobby owner may
|
// Origin/main compatibility: legacy clients did not distinguish automatic
|
||||||
// relay legacy PREPARE/EXECUTE/CANCEL.
|
// and manual Force Sync. A non-lobby-owner command must therefore keep its
|
||||||
const legacyEpisodeRid = 'episode-legacy-owner-'+Date.now();
|
// historical relay semantics instead of being silently discarded.
|
||||||
|
const legacyEpisodeRid = 'episode-legacy-compat-'+Date.now();
|
||||||
const legacyEpisodeA = await c(), legacyEpisodeB = await c();
|
const legacyEpisodeA = await c(), legacyEpisodeB = await c();
|
||||||
await j(legacyEpisodeA, legacyEpisodeRid, 'legacy-a');
|
await j(legacyEpisodeA, legacyEpisodeRid, 'legacy-a');
|
||||||
await j(legacyEpisodeB, legacyEpisodeRid, 'legacy-b');
|
await j(legacyEpisodeB, legacyEpisodeRid, 'legacy-b');
|
||||||
legacyEpisodeA._m.length = legacyEpisodeB._m.length = 0;
|
legacyEpisodeA._m.length = legacyEpisodeB._m.length = 0;
|
||||||
s(legacyEpisodeA, 'episode_lobby', { expectedTitle: 'S01E11' });
|
s(legacyEpisodeA, 'episode_lobby', { expectedTitle: 'S01E11' });
|
||||||
await w(legacyEpisodeB, 'episode_lobby');
|
await w(legacyEpisodeB, 'episode_lobby');
|
||||||
s(legacyEpisodeB, 'force_sync_prepare', { targetTime: 0 });
|
s(legacyEpisodeB, 'force_sync_prepare', { targetTime: 17 });
|
||||||
let nonOwnerPrepareRelayed = false;
|
const nonOwnerPrepare = await w(legacyEpisodeA, 'force_sync_prepare');
|
||||||
try { await w(legacyEpisodeA, 'force_sync_prepare', 300); nonOwnerPrepareRelayed = true; } catch { /* expected */ }
|
assert.equal(nonOwnerPrepare.targetTime, 17);
|
||||||
assert.equal(nonOwnerPrepareRelayed, false);
|
|
||||||
s(legacyEpisodeA, 'force_sync_prepare', { targetTime: 0 });
|
|
||||||
await w(legacyEpisodeB, 'force_sync_prepare');
|
|
||||||
s(legacyEpisodeB, 'force_sync_execute', {});
|
s(legacyEpisodeB, 'force_sync_execute', {});
|
||||||
let nonOwnerExecuteRelayed = false;
|
await w(legacyEpisodeA, 'force_sync_execute');
|
||||||
try { await w(legacyEpisodeA, 'force_sync_execute', 300); nonOwnerExecuteRelayed = true; } catch { /* expected */ }
|
assert.equal(mod.rooms.get(legacyEpisodeRid).mediaState.currentTime, 17);
|
||||||
assert.equal(nonOwnerExecuteRelayed, false);
|
|
||||||
s(legacyEpisodeA, 'force_sync_execute', {});
|
|
||||||
await w(legacyEpisodeB, 'force_sync_execute');
|
|
||||||
close();
|
close();
|
||||||
resetConnectionRate();
|
resetConnectionRate();
|
||||||
|
|
||||||
@@ -1238,12 +1381,18 @@ try {
|
|||||||
mxo._m.length = mxn._m.length = 0;
|
mxo._m.length = mxn._m.length = 0;
|
||||||
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
|
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
|
||||||
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
|
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');
|
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',{});
|
s(mxn,'episode_lobby_cancel',{});
|
||||||
let staleMixedCancelDropped = false;
|
await w(mxo,'episode_lobby_cancel');
|
||||||
try { await w(mxo,'episode_lobby_cancel',300); } catch { staleMixedCancelDropped = true; }
|
assert.equal(mod.rooms.get(mxrid).activeLobby, null);
|
||||||
assert.ok(staleMixedCancelDropped, 'stale legacy lobby cancel is dropped after manual playback supersedes it');
|
|
||||||
close();
|
close();
|
||||||
resetConnectionRate();
|
resetConnectionRate();
|
||||||
|
|
||||||
@@ -1305,7 +1454,7 @@ try {
|
|||||||
|
|
||||||
console.log('All WebSocket integration tests passed (incl. host control mode)');
|
console.log('All WebSocket integration tests passed (incl. host control mode)');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('FAILED:', e.message);
|
console.error('FAILED:', e.stack || e.message);
|
||||||
process.exitCode=1;
|
process.exitCode=1;
|
||||||
} finally {
|
} finally {
|
||||||
close();
|
close();
|
||||||
|
|||||||
+119
-48
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import dotenv from 'dotenv';
|
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 { createChatEnvelope } from './chat.js';
|
||||||
import {
|
import {
|
||||||
commitForceSyncMediaState,
|
commitForceSyncMediaState,
|
||||||
@@ -189,15 +189,18 @@ const SEQUENCED_ROOM_EVENTS = new Set([
|
|||||||
EVENTS.FORCE_SYNC_EXECUTE
|
EVENTS.FORCE_SYNC_EXECUTE
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EPISODE_SYNC_V2_SUPERSEDING_EVENTS = new Set([
|
const EPISODE_SYNC_V2_HARD_SUPERSEDING_EVENTS = new Set([
|
||||||
EVENTS.PLAY,
|
|
||||||
EVENTS.PAUSE,
|
|
||||||
EVENTS.SEEK,
|
|
||||||
EVENTS.FORCE_SYNC_PREPARE,
|
EVENTS.FORCE_SYNC_PREPARE,
|
||||||
EVENTS.FORCE_SYNC_EXECUTE,
|
EVENTS.FORCE_SYNC_EXECUTE,
|
||||||
EVENTS.EPISODE_LOBBY
|
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
|
// 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
|
// 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).
|
// 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);
|
&& 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) {
|
function publicEpisodeSyncV2(transaction, phase = null) {
|
||||||
if (!transaction) return 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 {
|
return {
|
||||||
transactionId: transaction.transactionId,
|
transactionId: transaction.transactionId,
|
||||||
phase: publicPhase,
|
phase: publicPhase,
|
||||||
expectedTitle: transaction.expectedTitle,
|
expectedTitle: transaction.expectedTitle,
|
||||||
|
expectedEpisodeId: transaction.expectedEpisodeId || null,
|
||||||
initiatorPeerId: transaction.initiatorPeerId,
|
initiatorPeerId: transaction.initiatorPeerId,
|
||||||
participants: [...transaction.participants],
|
participants: [...transaction.participants],
|
||||||
loadedPeers: [...transaction.loadedPeers],
|
loadedPeers: [...transaction.loadedPeers],
|
||||||
preparedPeers: [...transaction.preparedPeers],
|
preparedPeers: [...transaction.preparedPeers],
|
||||||
|
executedPeers: [...(transaction.executedPeers || [])],
|
||||||
createdAt: transaction.createdAt,
|
createdAt: transaction.createdAt,
|
||||||
deadlineAt: transaction.deadlineAt,
|
remainingMs: Number.isFinite(transaction.deadlineAt)
|
||||||
|
? Math.max(0, transaction.deadlineAt - Date.now())
|
||||||
|
: 0,
|
||||||
revision: transaction.revision
|
revision: transaction.revision
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -269,17 +286,30 @@ function clearEpisodeSyncV2Timer(transaction) {
|
|||||||
transaction.timeout = null;
|
transaction.timeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEpisodeSyncV2(roomId, room, reason, failedPeerId = null) {
|
function cancelEpisodeSyncV2(roomId, room, reason, failedPeerId = null, { settlePaused = false } = {}) {
|
||||||
const transaction = room?.episodeSyncV2;
|
const transaction = room?.episodeSyncV2;
|
||||||
if (!transaction) return false;
|
if (!transaction) return false;
|
||||||
clearEpisodeSyncV2Timer(transaction);
|
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.episodeSyncV2 = null;
|
||||||
room.lastEpisodeSyncV2Id = transaction.transactionId;
|
room.lastEpisodeSyncV2Id = transaction.transactionId;
|
||||||
emitEpisodeSyncV2ToParticipants(roomId, room, transaction, {
|
emitEpisodeSyncV2ToParticipants(roomId, room, transaction, {
|
||||||
...publicEpisodeSyncV2(transaction, 'cancel'),
|
...publicEpisodeSyncV2(transaction, 'cancel'),
|
||||||
senderId: transaction.initiatorPeerId,
|
senderId: transaction.initiatorPeerId,
|
||||||
reason: typeof reason === 'string' ? reason.substring(0, 32) : 'cancelled',
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -289,7 +319,12 @@ function scheduleEpisodeSyncV2Deadline(roomId, room, transaction, timeoutMs) {
|
|||||||
transaction.deadlineAt = Date.now() + timeoutMs;
|
transaction.deadlineAt = Date.now() + timeoutMs;
|
||||||
transaction.timeout = setTimeout(() => {
|
transaction.timeout = setTimeout(() => {
|
||||||
if (room.episodeSyncV2 !== transaction) return;
|
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);
|
}, timeoutMs);
|
||||||
transaction.timeout.unref?.();
|
transaction.timeout.unref?.();
|
||||||
}
|
}
|
||||||
@@ -306,7 +341,12 @@ export function expireEpisodeSyncV2Transactions(now = Date.now()) {
|
|||||||
for (const [roomId, room] of rooms) {
|
for (const [roomId, room] of rooms) {
|
||||||
const transaction = room.episodeSyncV2;
|
const transaction = room.episodeSyncV2;
|
||||||
if (transaction && Number.isFinite(transaction.deadlineAt) && transaction.deadlineAt <= now) {
|
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
|
// V2 freezes its participant set. Losing any participant invalidates the
|
||||||
// barrier; continuing with a smaller set could execute before a reconnecting
|
// barrier; continuing with a smaller set could execute before a reconnecting
|
||||||
// player has actually prepared.
|
// 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);
|
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);
|
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
||||||
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
||||||
room.activeLobby = null; // Dissolve lobby
|
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
|
// 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
|
// 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
|
// Cleanup old room if re-joining
|
||||||
const oldMapping = socketToRoom.get(socket.id);
|
const oldMapping = socketToRoom.get(socket.id);
|
||||||
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
|
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;
|
socket.data.clientCapabilities = clientCapabilities;
|
||||||
return; // Already in this room with same peerId, ignore to prevent spam
|
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.
|
// transaction explicitly replaced by newer room playback.
|
||||||
forceSyncSuperseded: false,
|
forceSyncSuperseded: false,
|
||||||
activeLobby: null,
|
activeLobby: null,
|
||||||
legacyEpisodeSyncOwner: null,
|
|
||||||
episodeSyncV2: null,
|
episodeSyncV2: null,
|
||||||
lastEpisodeSyncV2Id: null
|
lastEpisodeSyncV2Id: null
|
||||||
};
|
};
|
||||||
@@ -646,6 +686,14 @@ io.on('connection', (socket) => {
|
|||||||
return;
|
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
|
// Peer Deduplication: Remove existing socket for the same peerId
|
||||||
const dedupeSids = [];
|
const dedupeSids = [];
|
||||||
for (const [sid, data] of room.peerData.entries()) {
|
for (const [sid, data] of room.peerData.entries()) {
|
||||||
@@ -865,23 +913,20 @@ io.on('connection', (socket) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A user/manual legacy command wins over automation. Cancel
|
// Loading deliberately tolerates raw player PLAY/PAUSE/SEEK
|
||||||
// the v2 barrier first so prepared peers can restore safely,
|
// churn from source swaps. During PREPARE those commands are
|
||||||
// then relay the newer command normally.
|
// newer room intent and release already-paused peers. Force
|
||||||
if (room.episodeSyncV2 && EPISODE_SYNC_V2_SUPERSEDING_EVENTS.has(eventName)) {
|
// 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);
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaStateNow = Date.now();
|
const mediaStateNow = Date.now();
|
||||||
@@ -911,16 +956,6 @@ io.on('connection', (socket) => {
|
|||||||
room.forceSyncSuperseded = true;
|
room.forceSyncSuperseded = true;
|
||||||
room.forceSyncInitiator = null;
|
room.forceSyncInitiator = null;
|
||||||
room.forceSyncTarget = 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) {
|
if (eventName === EVENTS.FORCE_SYNC_PREPARE) {
|
||||||
// A malformed PREPARE must neither pause peers nor grant
|
// A malformed PREPARE must neither pause peers nor grant
|
||||||
@@ -971,7 +1006,6 @@ io.on('connection', (socket) => {
|
|||||||
room.forceSyncInitiator = null;
|
room.forceSyncInitiator = null;
|
||||||
room.forceSyncTarget = null;
|
room.forceSyncTarget = null;
|
||||||
room.forceSyncSuperseded = false;
|
room.forceSyncSuperseded = false;
|
||||||
room.legacyEpisodeSyncOwner = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
||||||
@@ -983,14 +1017,12 @@ io.on('connection', (socket) => {
|
|||||||
initiatorPeerId: mapping.peerId,
|
initiatorPeerId: mapping.peerId,
|
||||||
readyPeers: [mapping.peerId]
|
readyPeers: [mapping.peerId]
|
||||||
};
|
};
|
||||||
room.legacyEpisodeSyncOwner = mapping.peerId;
|
|
||||||
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
|
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
|
||||||
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
|
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
|
||||||
room.activeLobby.readyPeers.push(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) {
|
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
|
||||||
room.activeLobby = null;
|
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'
|
const expectedTitle = typeof data.expectedTitle === 'string'
|
||||||
? data.expectedTitle.substring(0, 100)
|
? data.expectedTitle.substring(0, 100)
|
||||||
: '';
|
: '';
|
||||||
|
const expectedEpisodeId = normalizeExpectedEpisodeId(data.expectedEpisodeId);
|
||||||
const isController = room.controlMode !== CONTROL_MODES.HOST_ONLY
|
const isController = room.controlMode !== CONTROL_MODES.HOST_ONLY
|
||||||
|| (room.controllers && room.controllers.has(mapping.peerId));
|
|| (room.controllers && room.controllers.has(mapping.peerId));
|
||||||
const senderData = room.peerData.get(socket.id);
|
const senderData = room.peerData.get(socket.id);
|
||||||
@@ -1030,6 +1063,7 @@ io.on('connection', (socket) => {
|
|||||||
transactionId: null,
|
transactionId: null,
|
||||||
senderId: mapping.peerId,
|
senderId: mapping.peerId,
|
||||||
expectedTitle,
|
expectedTitle,
|
||||||
|
expectedEpisodeId,
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
if (!expectedTitle) return rejectStart('invalid_title');
|
if (!expectedTitle) return rejectStart('invalid_title');
|
||||||
@@ -1062,17 +1096,19 @@ io.on('connection', (socket) => {
|
|||||||
transactionId: crypto.randomUUID(),
|
transactionId: crypto.randomUUID(),
|
||||||
phase: 'loading',
|
phase: 'loading',
|
||||||
expectedTitle,
|
expectedTitle,
|
||||||
|
expectedEpisodeId,
|
||||||
initiatorPeerId: mapping.peerId,
|
initiatorPeerId: mapping.peerId,
|
||||||
participants,
|
participants,
|
||||||
loadedPeers: [],
|
loadedPeers: [],
|
||||||
preparedPeers: [],
|
preparedPeers: [],
|
||||||
|
executedPeers: [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
deadlineAt: now + EPISODE_LOBBY_TIMEOUT,
|
deadlineAt: now + EPISODE_SYNC_V2_LOAD_TIMEOUT,
|
||||||
revision: 1,
|
revision: 1,
|
||||||
timeout: null
|
timeout: null
|
||||||
};
|
};
|
||||||
room.episodeSyncV2 = transaction;
|
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, {
|
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
||||||
...publicEpisodeSyncV2(transaction, 'lobby'),
|
...publicEpisodeSyncV2(transaction, 'lobby'),
|
||||||
senderId: transaction.initiatorPeerId
|
senderId: transaction.initiatorPeerId
|
||||||
@@ -1089,7 +1125,19 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (phase === 'cancel' || phase === 'failed') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,6 +1177,28 @@ io.on('connection', (socket) => {
|
|||||||
return;
|
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);
|
clearEpisodeSyncV2Timer(transaction);
|
||||||
room.episodeSyncV2 = null;
|
room.episodeSyncV2 = null;
|
||||||
room.lastEpisodeSyncV2Id = transaction.transactionId;
|
room.lastEpisodeSyncV2Id = transaction.transactionId;
|
||||||
@@ -1143,9 +1213,10 @@ io.on('connection', (socket) => {
|
|||||||
transaction.expectedTitle
|
transaction.expectedTitle
|
||||||
);
|
);
|
||||||
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
||||||
...publicEpisodeSyncV2(transaction, 'execute'),
|
...publicEpisodeSyncV2(transaction, 'complete'),
|
||||||
senderId: transaction.initiatorPeerId,
|
senderId: transaction.initiatorPeerId,
|
||||||
targetTime: 0
|
targetTime: 0,
|
||||||
|
remainingMs: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+4
-2
@@ -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.HOST_CONTROL`: relay supports host-only room authority.
|
||||||
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
|
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
|
||||||
- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots.
|
- `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`.
|
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_LOBBY` | Bidirectional relay | Episode transition lobby started |
|
||||||
| `EPISODE_READY` | Bidirectional relay | Peer has loaded the episode and is ready |
|
| `EPISODE_READY` | Bidirectional relay | Peer has loaded the episode and is ready |
|
||||||
| `EPISODE_LOBBY_CANCEL` | Bidirectional relay | Active episode lobby cancelled |
|
| `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 |
|
| `GET_ROOMS` / `ROOM_LIST` | Client <-> Server | Room discovery with server-side cooldown |
|
||||||
| `PING` / `PONG` | Client <-> Server/Peer | Server RTT and peer latency checks |
|
| `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_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.
|
- `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_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_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`.
|
- `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.
|
- `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// 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 FORCE_SYNC_TARGET_DELAY_WARNING = FORCE_SYNC_TIMEOUT + 2000;
|
||||||
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
|
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_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;
|
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);
|
||||||
|
});
|
||||||
@@ -1598,10 +1598,102 @@ test('completes Episode Sync v2 only after the packed player is stably prepared'
|
|||||||
lobby.transactionId
|
lobby.transactionId
|
||||||
);
|
);
|
||||||
expect(execute).toMatchObject({ phase: 'execute', transactionId: lobby.transactionId, targetTime: 0 });
|
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(() => 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.paused)).toBe(false);
|
||||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(3);
|
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') && (() => {
|
const legacyForceFrames = coordinator.messages.filter(message => message.startsWith('42') && (() => {
|
||||||
try { return JSON.parse(message.substring(2))[0].startsWith('force_sync_'); } catch { return false; }
|
try { return JSON.parse(message.substring(2))[0].startsWith('force_sync_'); } catch { return false; }
|
||||||
})());
|
})());
|
||||||
|
|||||||
Reference in New Issue
Block a user