fix(sync): make episode transitions relay-authoritative

This commit is contained in:
Timo
2026-09-01 03:21:02 +02:00
parent 0dd6f5bba5
commit 77e7d0c103
17 changed files with 1492 additions and 97 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ describe('async room-session guards', () => {
it('revalidates ROOM_DATA after every asynchronous join boundary', () => {
const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:');
expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2);
expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
expect(roomData).toContain('const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(');
});
it('does not return chat context after its room or target changed', () => {
+292 -74
View File
@@ -1,7 +1,7 @@
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
import { sameEpisode, sameEpisodeStrict, extractEpisodeId } from './episode-utils.js';
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
import { initTabManager } from './modules/tab-manager.js';
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
@@ -290,7 +290,8 @@ function serverSupportsChat() {
}
const CLIENT_CAPABILITIES = Object.freeze([
CAPABILITIES.CHAT_V1,
CAPABILITIES.MEDIA_STATE_V1
CAPABILITIES.MEDIA_STATE_V1,
CAPABILITIES.EPISODE_SYNC_V2
]);
function persistCanonicalMediaRecovery() {
@@ -462,7 +463,7 @@ function ensureState() {
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo',
'selectedTabId', 'selectedTabTitle', 'selectionErrorTabId', 'selectionErrorMessage',
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
'episodeLobby', 'episodeSyncV2', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
'hcmDesynced', 'chatActivityTimeline', 'canonicalMediaRecovery'
], (data) => {
// A late callback must not resurrect a room, queue or canonical
@@ -583,6 +584,20 @@ function ensureState() {
}
}
const restoredEpisodeSyncV2 = currentRoom
? normalizeEpisodeSyncV2(
data.episodeSyncV2 || currentRoom.episodeSyncV2,
new Set(currentRoom.peers.map(candidate => candidate.peerId))
)
: null;
if (restoredEpisodeSyncV2) {
episodeSyncV2 = restoredEpisodeSyncV2;
if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout);
episodeLobbyTimeout = null;
episodeLobby = null;
currentRoom.activeLobby = null;
}
if (Number.isSafeInteger(data.localSeq) && data.localSeq >= 0) localSeq = data.localSeq;
eventQueue = normalizePersistedEventQueue(
[...eventQueue, ...(Array.isArray(data.eventQueue) ? data.eventQueue : [])],
@@ -661,6 +676,7 @@ let forceSyncTimeout = null;
// Episode Auto-Sync Lobby
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
let episodeLobbyTimeout = null;
let episodeSyncV2 = null;
// --- Storage Utils ---
@@ -1192,6 +1208,46 @@ async function clearTargetSelectionForLifecycle({
return true;
}
function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const transactionId = typeof value.transactionId === 'string'
? value.transactionId.substring(0, 64)
: '';
const phase = value.phase === 'lobby' || value.phase === 'prepare' ? value.phase : '';
const expectedTitle = typeof value.expectedTitle === 'string'
? value.expectedTitle.substring(0, 100)
: '';
const initiatorPeerId = typeof value.initiatorPeerId === 'string'
? value.initiatorPeerId.substring(0, 16)
: '';
if (!transactionId || !phase || !expectedTitle || !initiatorPeerId
|| !Array.isArray(value.participants)
|| !Array.isArray(value.loadedPeers)
|| !Array.isArray(value.preparedPeers)) return null;
const normalizePeers = peers => [...new Set(peers
.filter(candidate => typeof candidate === 'string' && candidate)
.map(candidate => candidate.substring(0, 16)))];
const participants = normalizePeers(value.participants);
const participantSet = new Set(participants);
const loadedPeers = normalizePeers(value.loadedPeers).filter(candidate => participantSet.has(candidate));
const preparedPeers = normalizePeers(value.preparedPeers).filter(candidate => participantSet.has(candidate));
if (participants.length < 2
|| !participantSet.has(initiatorPeerId)
|| (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null;
return {
transactionId,
phase,
expectedTitle,
initiatorPeerId,
participants,
loadedPeers,
preparedPeers,
createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(),
deadlineAt: Number.isFinite(value.deadlineAt) ? value.deadlineAt : null,
revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1
};
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
return clearTargetSelectionForLifecycle({
expectedTabId,
@@ -1212,6 +1268,8 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
// Stop room-specific polling before the content script itself is removed.
// Every terminal room exit must pass through the exact target identity while
// it is still available, regardless of who initiated the exit.
if (notifyServer) cancelEpisodeSyncV2('room_exit');
else clearEpisodeSyncV2State({ reason: 'room_exit' });
clearEpisodeLobbyState();
currentRoom = null;
clearCanonicalMediaRecovery();
@@ -1249,6 +1307,7 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
forceSyncDeadline: null,
expectedAcksCount: 0,
episodeLobby: null,
episodeSyncV2: null,
hcmDesynced: false,
reconnectFailed: false,
reconnectAttempts: 0,
@@ -1845,7 +1904,7 @@ async function flushEventQueue(replaySettingsOverride = undefined) {
}
applyQueuedRoomPolicy(flushRoomId, {
canControl: !(controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController()),
activeLobby: !!episodeLobby,
activeLobby: !!(episodeLobby || episodeSyncV2),
desynced: hcmDesynced,
authoritativeLobby: !!currentRoom?.activeLobby
}, 'Queue replay authority changed');
@@ -2020,9 +2079,9 @@ async function performPendingCanonicalMediaStateApply() {
addLog(`Canonical media state r${mediaState.revision} skipped: local guest is desynced`, 'info');
return { status: 'ignored_desynced' };
}
if (episodeLobby) {
if (episodeLobby || episodeSyncV2) {
markCanonicalMediaStateHandled(roomId, mediaState.revision);
addLog(`Canonical media state r${mediaState.revision} skipped: episode lobby is active`, 'info');
addLog(`Canonical media state r${mediaState.revision} skipped: episode sync is active`, 'info');
return { status: 'ignored_episode_lobby' };
}
@@ -2193,6 +2252,29 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
currentRoom.peers = [];
}
const roomPeerIds = new Set(currentRoom.peers.map(candidate => candidate.peerId));
const authoritativeEpisodeSyncV2 = serverSupports(CAPABILITIES.EPISODE_SYNC_V2)
? normalizeEpisodeSyncV2(data.episodeSyncV2, roomPeerIds)
: null;
if (data.episodeSyncV2 && !authoritativeEpisodeSyncV2) {
addLog('Ignored malformed Episode Sync v2 transaction in ROOM_DATA', 'warn');
}
if (authoritativeEpisodeSyncV2) {
const shouldNotifyContent = !episodeSyncV2
|| episodeSyncV2.transactionId !== authoritativeEpisodeSyncV2.transactionId
|| episodeSyncV2.phase !== authoritativeEpisodeSyncV2.phase;
if (episodeLobby) clearEpisodeLobbyState();
episodeSyncV2 = authoritativeEpisodeSyncV2;
currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2;
persistEpisodeSyncV2();
broadcastLobbyUpdate();
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
} else if (episodeSyncV2) {
clearEpisodeSyncV2State({ reason: 'relay_state_ended' });
} else {
currentRoom.episodeSyncV2 = null;
}
// ROOM_DATA is authoritative for an already-active server lobby,
// but a locally-created offline lobby has not reached the relay
// yet and must remain owned by its initiator until queued replay.
@@ -2200,7 +2282,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
entry?.event === EVENTS.EPISODE_LOBBY
&& (!entry.roomId || entry.roomId === data.roomId)
);
const authoritativeLobby = normalizeEpisodeLobby(
const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(
data.activeLobby,
Date.now(),
new Set(currentRoom.peers.map(candidate => candidate.peerId))
@@ -2274,12 +2356,12 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
&& !amController();
const queuePolicy = applyQueuedRoomPolicy(data.roomId, {
canControl: !lostRoomAuthority,
activeLobby: !!episodeLobby,
activeLobby: !!(episodeLobby || episodeSyncV2),
desynced: hcmDesynced,
authoritativeLobby: !!authoritativeLobby
authoritativeLobby: !!(authoritativeLobby || authoritativeEpisodeSyncV2)
}, lostRoomAuthority
? 'Host Control role changed while offline'
: (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy'));
: ((episodeLobby || episodeSyncV2) ? 'Active Episode Sync takes precedence' : 'Reconnect queue policy'));
await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent);
await flushEventQueue(replaySettings);
@@ -2608,6 +2690,76 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
}
}
break;
case EVENTS.EPISODE_SYNC_V2: {
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
addLog('Ignored Episode Sync v2 event from relay without advertised capability', 'warn');
break;
}
const phase = data.phase;
if (phase === 'cancel' && !data.transactionId) {
addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn');
break;
}
if (phase === 'lobby' || phase === 'prepare') {
const activePeerIds = new Set((currentRoom?.peers || []).map(candidate =>
typeof candidate === 'object' ? candidate.peerId : candidate
));
const incoming = normalizeEpisodeSyncV2(data, activePeerIds);
if (!incoming
|| !incoming.participants.includes(peerId)
|| data.senderId !== incoming.initiatorPeerId) {
addLog('Ignored malformed Episode Sync v2 state', 'warn');
break;
}
if (episodeSyncV2
&& episodeSyncV2.transactionId === incoming.transactionId
&& incoming.revision < episodeSyncV2.revision) {
addLog(`Ignored stale Episode Sync v2 revision ${incoming.revision}`, 'warn');
break;
}
const shouldNotifyContent = !episodeSyncV2
|| episodeSyncV2.transactionId !== incoming.transactionId
|| episodeSyncV2.phase !== incoming.phase;
if (episodeSyncV2 && episodeSyncV2.transactionId !== incoming.transactionId) {
clearEpisodeSyncV2State({ reason: 'transaction_replaced' });
}
if (episodeLobby) clearEpisodeLobbyState();
episodeSyncV2 = incoming;
if (currentRoom) currentRoom.episodeSyncV2 = incoming;
persistEpisodeSyncV2();
broadcastLobbyUpdate();
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info');
break;
}
if ((phase === 'execute' || phase === 'cancel')
&& episodeSyncV2
&& data.transactionId === episodeSyncV2.transactionId) {
const completed = episodeSyncV2;
if (phase === 'execute') {
sendMessageToCurrentContent({
type: 'EPISODE_SYNC_V2',
transaction: { ...completed, phase: 'execute', targetTime: 0 }
}).catch(() => {});
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers.forEach(candidate => {
if (candidate && typeof candidate === 'object') {
candidate.playbackState = 'playing';
candidate.currentTime = 0;
candidate.lastReactiveUpdate = Date.now();
}
});
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' });
addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success');
} else {
clearEpisodeSyncV2State({ reason: data.reason || 'cancelled' });
addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn');
}
}
break;
}
case EVENTS.EPISODE_LOBBY:
if (typeof data.senderId === 'string'
&& typeof data.expectedTitle === 'string'
@@ -2768,11 +2920,13 @@ function executeForceSync() {
}
function completeForceSyncBeforeTargetChange(nextTabId) {
if (!isForceSyncInitiator) return;
const selectedTabId = normalizeTabId(currentTabId);
const normalizedNextTabId = normalizeTabId(nextTabId);
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
if (episodeSyncV2) cancelEpisodeSyncV2('target_changed');
if (!isForceSyncInitiator) return;
addLog('Finishing Force Sync before target change', 'info');
executeForceSync();
}
@@ -2782,8 +2936,66 @@ function persistEpisodeLobby() {
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
}
function episodeLobbyForUi() {
if (!episodeSyncV2) return episodeLobby;
return {
expectedTitle: episodeSyncV2.expectedTitle,
initiatorPeerId: episodeSyncV2.initiatorPeerId,
readyPeers: episodeSyncV2.phase === 'prepare'
? [...episodeSyncV2.preparedPeers]
: [...episodeSyncV2.loadedPeers],
createdAt: episodeSyncV2.createdAt,
mode: 'v2',
phase: episodeSyncV2.phase,
transactionId: episodeSyncV2.transactionId
};
}
function broadcastLobbyUpdate() {
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {});
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobbyForUi() }).catch(() => {});
}
function persistEpisodeSyncV2() {
if (!storageInitialized) return;
chrome.storage.session.set({ episodeSyncV2, currentRoom }).catch(() => {});
}
function sendEpisodeSyncV2ToContent(transaction = episodeSyncV2) {
if (!transaction || !currentTabId) return Promise.resolve();
return sendMessageToCurrentContent({
type: 'EPISODE_SYNC_V2',
transaction: { ...transaction }
});
}
function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' } = {}) {
const previous = episodeSyncV2;
episodeSyncV2 = null;
if (currentRoom) currentRoom.episodeSyncV2 = null;
persistEpisodeSyncV2();
broadcastLobbyUpdate();
if (notifyContent && previous && currentTabId) {
sendMessageToCurrentContent({
type: 'EPISODE_SYNC_V2',
transaction: {
...previous,
phase: 'cancel',
reason
}
}).catch(() => {});
}
}
function cancelEpisodeSyncV2(reason = 'cancelled') {
if (!episodeSyncV2) return false;
const transaction = episodeSyncV2;
emitLive(EVENTS.EPISODE_SYNC_V2, {
phase: 'cancel',
transactionId: transaction.transactionId,
reason
});
clearEpisodeSyncV2State({ reason });
return true;
}
function clearEpisodeLobbyState() {
@@ -2808,7 +3020,7 @@ function cancelEpisodeLobby(reason) {
supersedeCanonicalMediaRecovery('local episode_lobby_cancel');
// Broadcast cancellation to room
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
emitLive(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
clearEpisodeLobbyState();
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
@@ -2869,7 +3081,7 @@ function executeEpisodeLobby() {
expectedAcksCount: expectedAcksCount
});
const syncPayload = { targetTime: 0.0 };
const syncPayload = { targetTime: 0.0, mediaTitle: title };
localSeq++;
chrome.storage.session.set({ localSeq });
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp, seq: localSeq });
@@ -2885,6 +3097,7 @@ function executeEpisodeLobby() {
function checkEpisodeLobbyCompletion() {
if (!episodeLobby || !currentRoom) return;
if (episodeLobby.initiatorPeerId !== peerId) return;
const peers = Array.isArray(currentRoom.peers) ? currentRoom.peers : [];
// M-3: desynced peers (watching on their own) sit out the lobby — their content
// script ignores EPISODE_LOBBY and never reports ready. Don't let them block
@@ -2895,7 +3108,7 @@ function checkEpisodeLobbyCompletion() {
.filter(Boolean));
const readyParticipatingCount = episodeLobby.readyPeers
.filter(candidate => participatingPeerIds.has(candidate)).length;
if (readyParticipatingCount >= participatingPeerIds.size) {
if (participatingPeerIds.size > 0 && readyParticipatingCount >= participatingPeerIds.size) {
executeEpisodeLobby();
}
}
@@ -4456,6 +4669,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== newRoomId) {
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_RESET' }).catch(() => {});
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
cancelEpisodeSyncV2('room_switch');
forceDisconnect();
currentRoom = null;
clearCanonicalMediaRecovery();
@@ -4680,7 +4894,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
pendingTargetHost: pendingTarget?.host ?? null,
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
pendingTargetRequestId: pendingTarget?.requestId ?? null,
episodeLobby: episodeLobby,
episodeLobby: episodeLobbyForUi(),
reconnectAttempts,
reconnectSlowMode: reconnectFailed,
queuedLogicalEvents: eventQueue.length,
@@ -4914,6 +5128,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
checkEpisodeLobbyCompletion();
}
}
if (hcmDesynced && episodeSyncV2?.participants.includes(peerId)) {
cancelEpisodeSyncV2('desynced');
}
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
@@ -5254,7 +5471,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else {
localSeq++;
chrome.storage.session.set({ localSeq });
emit(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
emitLive(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
@@ -5441,12 +5658,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return;
}
// Host Control Mode: a gated guest must NOT initiate an episode lobby — the
// server drops the guest's EPISODE_LOBBY, so the lobby would never complete
// and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In
// host-only the controllers (owner + co-hosts) drive episode sync; a plain
// guest just follows / snaps back. Use amController() for parity with the
// CONTENT_EVENT gate and the server's controllers-based check.
// Host Control Mode: only controllers may initiate the relay-owned
// transaction. Plain guests wait for the controller's accepted lobby.
if (controlMode === CONTROL_MODES.HOST_ONLY && !amController()) {
addLog(`Episode change ("${lobbyTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
sendResponse({ status: 'host_only_guest_skip' });
@@ -5463,57 +5676,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return;
}
// If lobby already exists for this title, just mark self ready
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, lobbyTitle)) {
if (!episodeLobby.readyPeers.includes(peerId)) {
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
emit(EVENTS.EPISODE_READY, {
peerId,
title: lobbyTitle,
expectedTitle: episodeLobby.expectedTitle
});
checkEpisodeLobbyCompletion();
}
sendResponse({ status: 'ready_sent' });
// Automatic episode sync v2 is relay-owned. Never self-pause or fall
// back to the legacy client-owned lobby: on an old/mixed relay that path
// can create multiple Force Sync initiators. Manual Force Sync remains
// available and unchanged.
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
addLog(`Episode change ("${lobbyTitle}") — relay lacks Episode Sync v2; automatic sync skipped safely.`, 'warn');
sendResponse({ status: 'episode_sync_v2_unsupported' });
return;
}
// Cancel any existing lobby for a different episode
if (episodeLobby) clearEpisodeLobbyState();
// Create new lobby
supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE);
episodeLobby = {
expectedTitle: lobbyTitle,
initiatorPeerId: peerId,
readyPeers: [peerId], // We are already ready
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Episode lobby created: "${lobbyTitle}"`, 'info');
// Tell content script to pause the video and start polling
// (This is the only place we pause — after confirming the feature is enabled)
if (sender.tab && sender.tab.id) {
sendMessageToFrame(sender.tab.id, sender.frameId, {
type: 'PAUSE_FOR_LOBBY',
expectedTitle: lobbyTitle
}, null, sender.documentId).catch(() => {});
if (episodeSyncV2 && sameEpisode(episodeSyncV2.expectedTitle, lobbyTitle)) {
sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId });
return;
}
// Broadcast to room
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle });
// Start timeout (Q1: Option B — cancel on timeout)
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT);
// Immediate check — maybe we're the only one in the room
checkEpisodeLobbyCompletion();
sendResponse({ status: 'lobby_created' });
if (episodeSyncV2) cancelEpisodeSyncV2('new_episode');
if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', expectedTitle: lobbyTitle })) {
addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn');
sendResponse({ status: 'episode_sync_v2_offline' });
return;
}
addLog(`Episode Sync v2 requested: "${lobbyTitle}"`, 'info');
sendResponse({ status: 'episode_sync_v2_requested' });
} else if (message.type === 'EPISODE_READY_LOCAL') {
if (sender.tab) {
if (!isCurrentContentSender(sender)) {
@@ -5542,7 +5725,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
lobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
emit(EVENTS.EPISODE_READY, {
emitLive(EVENTS.EPISODE_READY, {
peerId,
title: readyTitle,
expectedTitle: lobby.expectedTitle
@@ -5552,6 +5735,35 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
}
sendResponse({ status: 'ok' });
} else if (message.type === 'EPISODE_SYNC_V2_LOCAL') {
if (sender.tab && !isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_stale_target' });
return;
}
const transaction = episodeSyncV2;
const localPhase = message.phase;
const expectedLocalPhase = localPhase === 'loaded'
? 'lobby'
: (localPhase === 'prepared' ? 'prepare' : null);
if (!transaction
|| message.transactionId !== transaction.transactionId
|| (expectedLocalPhase && transaction.phase !== expectedLocalPhase)
|| !['loaded', 'prepared', 'failed'].includes(localPhase)) {
sendResponse({ status: 'ignored_stale_transaction' });
return;
}
const localTitle = message.payload?.title;
if (localPhase !== 'failed'
&& (typeof localTitle !== 'string' || !sameEpisodeStrict(localTitle, transaction.expectedTitle))) {
sendResponse({ status: 'ignored_episode_mismatch' });
return;
}
const sent = emitLive(EVENTS.EPISODE_SYNC_V2, {
phase: localPhase,
transactionId: transaction.transactionId,
reason: typeof message.reason === 'string' ? message.reason.substring(0, 32) : undefined
});
sendResponse({ status: sent ? 'sent' : 'offline' });
} else if (message.type === 'TITLE_PRIVACY_CHANGED') {
const privacyRoomId = currentRoom?.roomId || null;
const privacyLobby = episodeLobby;
@@ -5566,6 +5778,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
cancelEpisodeLobby('Title privacy changed');
}
}
if (episodeSyncV2) cancelEpisodeSyncV2('title_privacy_changed');
if (currentRoom) {
const sharedTitles = getSharedTitleFields(settings);
emit(EVENTS.PEER_STATUS, {
@@ -5650,13 +5863,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
requestCanonicalMediaRecoveryAttempt();
// Content script re-injected, check if there's an active lobby
if (episodeLobby) {
if (episodeSyncV2) {
sendResponse({ episodeSyncV2: { ...episodeSyncV2 }, lobbyActive: false });
} else if (episodeLobby) {
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
} else {
sendResponse({ lobbyActive: false });
}
} else if (message.type === 'CANCEL_EPISODE_LOBBY') {
if (episodeLobby) {
if (episodeSyncV2) {
cancelEpisodeSyncV2('cancelled_by_user');
sendResponse({ status: 'ok' });
} else if (episodeLobby) {
cancelEpisodeLobby('Cancelled by user');
sendResponse({ status: 'ok' });
} else {
@@ -81,7 +81,7 @@ describe('canonical ROOM_DATA recovery contract', () => {
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
expect(apply).toContain('if (hcmDesynced)');
expect(apply).toContain('if (episodeLobby)');
expect(apply).toContain('if (episodeLobby || episodeSyncV2)');
expect(roomData).toContain('if (hasPendingLocalIntent)');
expect(backgroundSource).toContain('awaitingRoomData = true');
expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
+332 -2
View File
@@ -84,6 +84,7 @@
EPISODE_READY: "episode_ready"
};
const MAX_MEDIA_TIME = 86400;
const EPISODE_SYNC_V2_STABILITY_MS = 1000;
// --- SHARED_EVENTS_INJECT_END ---
// Suppresses native event reporting after a programmatic action.
@@ -334,6 +335,9 @@
let episodeTransitionDebounce = null;
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
let episodeSyncV2State = null;
let episodeSyncV2PollTimer = null;
let episodeSyncV2Generation = 0;
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
let _audioSettings = null;
let _audioProcessingAllowed = true;
@@ -1104,6 +1108,28 @@
if (idA || idB) return false;
return titleA === titleB;
}
function episodeContext(title) {
if (!title || typeof title !== 'string') return '';
return title
.replace(/S(?:eason\s*)?\d+[^a-zA-Z0-9]*E(?:pisode\s*)?\d+/ig, ' ')
.replace(/(?:Episode|Folge|Ep\.?)\s*\d+|#\s*\d+/ig, ' ')
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
}
// Transactional episode sync is deliberately stricter than ordinary playback
// filtering: if both peers expose contextual text (episode/show name), require
// it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced
// S/E-only titles still fall back to the canonical episode ID.
function sameEpisodeStrict(titleA, titleB) {
if (!sameEpisode(titleA, titleB)) return false;
const contextA = episodeContext(titleA);
const contextB = episodeContext(titleB);
return !contextA || !contextB || contextA === contextB;
}
// --- SHARED_EPISODE_UTILS_INJECT_END ---
// Returns true only when we are CERTAIN the episodes differ.
@@ -1215,6 +1241,244 @@
}
}
function isEpisodeSyncV2Current(state) {
return !destroyed
&& episodeSyncV2State === state
&& state.generation === episodeSyncV2Generation;
}
function stopEpisodeSyncV2Poll() {
if (episodeSyncV2PollTimer) {
clearInterval(episodeSyncV2PollTimer);
episodeSyncV2PollTimer = null;
}
}
async function reportEpisodeSyncV2Local(state, phase, reason = null) {
if (!isEpisodeSyncV2Current(state) || state.reportInFlight) return false;
state.reportInFlight = true;
try {
const response = await runtimeMessage({
type: 'EPISODE_SYNC_V2_LOCAL',
transactionId: state.transactionId,
phase,
reason,
payload: { title: getMediaTitle() }
});
if (!isEpisodeSyncV2Current(state)) return false;
if (response?.status === 'sent') {
if (phase === 'loaded') state.loadedReported = true;
if (phase === 'prepared') state.preparedReported = true;
return true;
}
return false;
} finally {
if (isEpisodeSyncV2Current(state)) state.reportInFlight = false;
}
}
function retryEpisodeSyncV2Report(state, phase, reason = null) {
if (!isEpisodeSyncV2Current(state) || state.reportRetryScheduled) return;
if (Number.isFinite(state.deadlineAt) && Date.now() >= state.deadlineAt) return;
state.reportRetryScheduled = true;
scheduleLifecycleTimeout(async () => {
if (!isEpisodeSyncV2Current(state)) return;
state.reportRetryScheduled = false;
const sent = await reportEpisodeSyncV2Local(state, phase, reason);
if (!sent) retryEpisodeSyncV2Report(state, phase, reason);
}, 750);
}
async function clearEpisodeSyncV2Content({ resume = false, manualAction = false } = {}) {
const state = episodeSyncV2State;
if (!state) return;
episodeSyncV2Generation++;
episodeSyncV2State = null;
stopEpisodeSyncV2Poll();
state.manualAction = state.manualAction || manualAction;
const video = state.video;
const mayResume = resume
&& state.pausedByTransaction
&& state.wasPlayingBeforePrepare
&& !state.manualAction
&& video
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
&& video.paused;
if (mayResume) {
const resumed = await tryMediaAction(EVENTS.PLAY);
if (!resumed) reportLog('Episode Sync v2: could not restore pre-transaction playback', 'warn');
}
}
function checkEpisodeSyncV2Loaded(state) {
if (!isEpisodeSyncV2Current(state) || state.phase !== 'lobby' || state.loadedReported) return;
const video = findVideo();
const title = getMediaTitle();
const matches = video
&& title
&& sameEpisodeStrict(title, state.expectedTitle)
&& video.readyState >= 1
&& getSyncCurrentTime(video) !== null;
if (!matches) {
state.loadCandidateVideo = null;
state.loadCandidateAt = 0;
return;
}
if (state.loadCandidateVideo !== video) {
state.loadCandidateVideo = video;
state.loadCandidateAt = Date.now();
return;
}
if (Date.now() - state.loadCandidateAt < 500) return;
reportEpisodeSyncV2Local(state, 'loaded').then(sent => {
if (sent) reportLog(`Episode Sync v2: loaded "${title}"`, 'success');
}).catch(() => {});
}
function startEpisodeSyncV2Lobby(transaction) {
if (!transaction?.transactionId || !transaction.expectedTitle) return;
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
episodeSyncV2State.phase = 'lobby';
episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt)
? transaction.deadlineAt
: episodeSyncV2State.deadlineAt;
return;
}
clearEpisodeSyncV2Content({ resume: true }).catch(() => {});
episodeSyncV2Generation++;
episodeSyncV2State = {
transactionId: transaction.transactionId,
expectedTitle: transaction.expectedTitle,
phase: 'lobby',
generation: episodeSyncV2Generation,
loadedReported: false,
preparedReported: false,
reportInFlight: false,
reportRetryScheduled: false,
deadlineAt: Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : null,
loadCandidateVideo: null,
loadCandidateAt: 0,
video: null,
wasPlayingBeforePrepare: false,
pausedByTransaction: false,
manualAction: false,
programmaticPausePending: false,
prepareStarted: false
};
stopEpisodeSyncV2Poll();
checkEpisodeSyncV2Loaded(episodeSyncV2State);
episodeSyncV2PollTimer = setInterval(() => {
if (episodeSyncV2State) checkEpisodeSyncV2Loaded(episodeSyncV2State);
}, 500);
}
function waitForEpisodeSyncV2Stable(state, video, targetTime = 0, timeoutMs = 12000) {
return new Promise(resolve => {
const startedAt = Date.now();
let stableSince = 0;
const timer = setInterval(() => {
const current = getSyncCurrentTime(video);
const ready = isEpisodeSyncV2Current(state)
&& state.phase === 'prepare'
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current - targetTime) < 1;
if (ready) {
if (!stableSince) stableSince = Date.now();
if (Date.now() - stableSince >= EPISODE_SYNC_V2_STABILITY_MS) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(true);
}
} else {
stableSince = 0;
}
if (!isEpisodeSyncV2Current(state) || Date.now() - startedAt >= timeoutMs) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
}
}, 100);
seekPollTimers.add(timer);
});
}
async function prepareEpisodeSyncV2(transaction) {
if (!transaction?.transactionId || !transaction.expectedTitle) return;
if (!episodeSyncV2State || episodeSyncV2State.transactionId !== transaction.transactionId) {
startEpisodeSyncV2Lobby({ ...transaction, phase: 'lobby' });
}
const state = episodeSyncV2State;
if (!state || state.transactionId !== transaction.transactionId) return;
state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt;
if (state.prepareStarted) return;
stopEpisodeSyncV2Poll();
state.phase = 'prepare';
state.prepareStarted = true;
const video = findVideo();
const currentTitle = getMediaTitle();
if (!video || !currentTitle || !sameEpisodeStrict(currentTitle, state.expectedTitle)) {
if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) {
retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch');
}
return;
}
state.video = video;
state.wasPlayingBeforePrepare = !video.paused;
if (!video.paused) {
state.programmaticPausePending = true;
const paused = await tryMediaAction(EVENTS.PAUSE);
if (!isEpisodeSyncV2Current(state)) return;
state.pausedByTransaction = paused === true;
if (!paused) {
state.programmaticPausePending = false;
if (!await reportEpisodeSyncV2Local(state, 'failed', 'pause_failed')) {
retryEpisodeSyncV2Report(state, 'failed', 'pause_failed');
}
return;
}
}
const current = getSyncCurrentTime(video);
if (current === null || Math.abs(current) >= 0.25) {
const sought = await tryMediaAction(EVENTS.SEEK, { targetTime: 0 });
if (!isEpisodeSyncV2Current(state)) return;
if (!sought) {
if (!await reportEpisodeSyncV2Local(state, 'failed', 'seek_failed')) {
retryEpisodeSyncV2Report(state, 'failed', 'seek_failed');
}
return;
}
}
const stable = await waitForEpisodeSyncV2Stable(state, video, 0);
if (!isEpisodeSyncV2Current(state)) return;
if (!stable) {
if (!await reportEpisodeSyncV2Local(state, 'failed', 'stability_timeout')) {
retryEpisodeSyncV2Report(state, 'failed', 'stability_timeout');
}
return;
}
if (await reportEpisodeSyncV2Local(state, 'prepared')) {
reportLog(`Episode Sync v2: prepared "${currentTitle}"`, 'success');
} else {
retryEpisodeSyncV2Report(state, 'prepared');
}
}
function failEpisodeSyncV2ForManualAction(action) {
const state = episodeSyncV2State;
if (!state || state.phase !== 'prepare') return;
state.manualAction = true;
reportEpisodeSyncV2Local(state, 'failed', `user_${action}`).catch(() => {});
clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {});
}
function getPlayerActionFixes() {
return [
{
@@ -1702,7 +1966,44 @@
}
}
// Episode Auto-Sync: Lobby notification from background
if (message.type === 'EPISODE_SYNC_V2') {
const transaction = message.transaction;
if (!transaction?.transactionId || !transaction.phase) {
sendResponse({ status: 'invalid_transaction' });
return true;
}
if (transaction.phase === 'lobby') {
reportLog(`Episode Sync v2 lobby: waiting for "${transaction.expectedTitle}"`, 'info');
startEpisodeSyncV2Lobby(transaction);
} else if (transaction.phase === 'prepare') {
prepareEpisodeSyncV2(transaction).catch(error => {
reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn');
});
} else if (transaction.phase === 'execute') {
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
const state = episodeSyncV2State;
const video = state.video || findVideo();
const canExecute = video
&& video === findVideo()
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle);
clearEpisodeSyncV2Content({ resume: false }).catch(() => {});
if (canExecute) {
Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => {
if (applied) scheduleProactiveHeartbeat();
else reportLog('Episode Sync v2 execute could not start playback', 'warn');
}).catch(() => {});
}
}
} else if (transaction.phase === 'cancel') {
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
clearEpisodeSyncV2Content({ resume: true }).catch(() => {});
}
}
sendResponse({ status: 'ok' });
return true;
}
// Episode Auto-Sync: Legacy lobby notification from background
if (message.type === 'EPISODE_LOBBY') {
// Host Control Mode: a desynced guest is watching on their own and must
// not join the lobby flow. Otherwise they'd pause on title match, report
@@ -1962,6 +2263,19 @@
return;
}
if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return;
if (episodeSyncV2State?.phase === 'prepare'
&& action === EVENTS.PAUSE
&& episodeSyncV2State.programmaticPausePending
&& video === episodeSyncV2State.video
&& video.paused) {
episodeSyncV2State.programmaticPausePending = false;
return;
}
// An unsuppressed native action during PREPARE is a newer user intent.
// Fail the automation before relaying that action; the relay broadcasts
// CANCEL first and then applies this manual PLAY/PAUSE/SEEK normally.
failEpisodeSyncV2ForManualAction(action);
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
cancelCanonicalMediaApply(action, video);
@@ -2037,6 +2351,7 @@
closeAudioContext();
if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; }
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
stopEpisodeSyncV2Poll();
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
// Drop any pending coalesced play/pause: we are tearing down and will
@@ -2064,6 +2379,12 @@
connectKeepAlivePort();
schedulePeriodicHeartbeat();
scheduleProactiveHeartbeat();
if (episodeSyncV2State?.phase === 'lobby') {
checkEpisodeSyncV2Loaded(episodeSyncV2State);
episodeSyncV2PollTimer = setInterval(() => {
if (episodeSyncV2State) checkEpisodeSyncV2Loaded(episodeSyncV2State);
}, 500);
}
reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn');
}
document.addEventListener('visibilitychange', handleVisibilityChange);
@@ -2445,6 +2766,9 @@
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
stopLobbyPoll();
stopEpisodeSyncV2Poll();
episodeSyncV2Generation++;
episodeSyncV2State = null;
hcmDeferredSnapPending = false;
observer.disconnect();
@@ -2501,7 +2825,13 @@
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
runtimeMessage({ type: 'CONTENT_BOOT' }, (res) => {
if (destroyed || chrome.runtime.lastError) return;
if (res && res.lobbyActive && res.expectedTitle) {
if (res?.episodeSyncV2) {
if (res.episodeSyncV2.phase === 'prepare') {
prepareEpisodeSyncV2(res.episodeSyncV2).catch(() => {});
} else {
startEpisodeSyncV2Lobby(res.episodeSyncV2);
}
} else if (res && res.lobbyActive && res.expectedTitle) {
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
startLobbyPoll(res.expectedTitle);
}
+3 -2
View File
@@ -53,7 +53,7 @@ describe('episode lobby completion races', () => {
it('validates restored and authoritative lobby state before using readyPeers', () => {
expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)');
expect(backgroundSource).toContain('data.currentRoom.activeLobby,');
expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
expect(backgroundSource).toContain('const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(');
expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))');
});
@@ -74,9 +74,10 @@ describe('episode lobby completion races', () => {
it('counts only ready peers who still participate in lobby completion', () => {
const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()');
expect(completion).toContain('episodeLobby.initiatorPeerId !== peerId');
expect(completion).toContain('const participatingPeerIds = new Set(peers');
expect(completion).toContain('participatingPeerIds.has(candidate)');
expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size');
expect(completion).toContain('participatingPeerIds.size > 0 && readyParticipatingCount >= participatingPeerIds.size');
});
it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => {
+105
View File
@@ -0,0 +1,105 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { CAPABILITIES, EVENTS, EPISODE_SYNC_V2_STABILITY_MS } from '../shared/constants.js';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const offlineSource = fs.readFileSync(path.join(extensionDir, 'offline-media-intent.js'), 'utf8');
const buildSource = fs.readFileSync(path.join(extensionDir, '..', 'scripts', 'build-extension.cjs'), 'utf8');
function between(source, startNeedle, endNeedle) {
const start = source.indexOf(startNeedle);
const end = source.indexOf(endNeedle, start + startNeedle.length);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
}
describe('Episode Sync v2 extension contract', () => {
it('advertises the additive capability without changing the protocol', () => {
expect(CAPABILITIES.EPISODE_SYNC_V2).toBe('episode-sync-v2');
expect(EVENTS.EPISODE_SYNC_V2).toBe('episode_sync_v2');
expect(backgroundSource).toContain('CAPABILITIES.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', () => {
expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2');
const episodeChanged = between(
backgroundSource,
"message.type === 'EPISODE_CHANGED'",
"message.type === 'EPISODE_READY_LOCAL'"
);
expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)');
expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start'");
expect(episodeChanged).not.toContain('PAUSE_FOR_LOBBY');
expect(episodeChanged).not.toContain('emit(EVENTS.EPISODE_LOBBY');
});
it('correlates content reports to the current transaction, phase, target and episode', () => {
const handler = between(
backgroundSource,
"message.type === 'EPISODE_SYNC_V2_LOCAL'",
"message.type === 'TITLE_PRIVACY_CHANGED'"
);
expect(handler).toContain('message.transactionId !== transaction.transactionId');
expect(handler).toContain('transaction.phase !== expectedLocalPhase');
expect(handler).toContain("!isCurrentContentSender(sender)");
expect(handler).toContain('!sameEpisodeStrict(localTitle, transaction.expectedTitle)');
expect(handler).toContain('emitLive(EVENTS.EPISODE_SYNC_V2');
});
it('requires the same player and episode to remain paused, seeked, buffered and stable', () => {
const stable = between(
contentSource,
'function waitForEpisodeSyncV2Stable(',
'async function prepareEpisodeSyncV2('
);
expect(stable).toContain('video === findVideo()');
expect(stable).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
expect(stable).toContain('video.paused');
expect(stable).toContain('!video.seeking');
expect(stable).toContain('video.readyState >= 3');
expect(stable).toContain('Math.abs(current - targetTime) < 1');
expect(stable).toContain('Date.now() - stableSince >= EPISODE_SYNC_V2_STABILITY_MS');
expect(EPISODE_SYNC_V2_STABILITY_MS).toBe(1000);
});
it('reports prepared only after awaited pause, seek and stable verification', () => {
const prepare = between(
contentSource,
'async function prepareEpisodeSyncV2(',
'function failEpisodeSyncV2ForManualAction('
);
const pause = prepare.indexOf('await tryMediaAction(EVENTS.PAUSE)');
const seek = prepare.indexOf('await tryMediaAction(EVENTS.SEEK');
const stable = prepare.indexOf('await waitForEpisodeSyncV2Stable');
const prepared = prepare.indexOf("reportEpisodeSyncV2Local(state, 'prepared')");
expect(pause).toBeGreaterThan(-1);
expect(seek).toBeGreaterThan(pause);
expect(stable).toBeGreaterThan(seek);
expect(prepared).toBeGreaterThan(stable);
});
it('restores playback only when this transaction paused a playing unchanged player', () => {
const clear = between(
contentSource,
'async function clearEpisodeSyncV2Content(',
'function checkEpisodeSyncV2Loaded('
);
expect(clear).toContain('state.pausedByTransaction');
expect(clear).toContain('state.wasPlayingBeforePrepare');
expect(clear).toContain('!state.manualAction');
expect(clear).toContain('video === findVideo()');
expect(clear).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)');
});
it('injects the shared stability window into packaged content scripts', () => {
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
expect(buildSource).toContain('episodeSyncStabilityVal');
});
});
+22
View File
@@ -22,3 +22,25 @@ export function sameEpisode(titleA, titleB) {
if (idA || idB) return false;
return titleA === titleB;
}
function episodeContext(title) {
if (!title || typeof title !== 'string') return '';
return title
.replace(/S(?:eason\s*)?\d+[^a-zA-Z0-9]*E(?:pisode\s*)?\d+/ig, ' ')
.replace(/(?:Episode|Folge|Ep\.?)\s*\d+|#\s*\d+/ig, ' ')
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
}
// Transactional episode sync is deliberately stricter than ordinary playback
// filtering: if both peers expose contextual text (episode/show name), require
// it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced
// S/E-only titles still fall back to the canonical episode ID.
export function sameEpisodeStrict(titleA, titleB) {
if (!sameEpisode(titleA, titleB)) return false;
const contextA = episodeContext(titleA);
const contextB = episodeContext(titleB);
return !contextA || !contextB || contextA === contextB;
}
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { extractEpisodeId, sameEpisode } from './episode-utils.js';
import { extractEpisodeId, sameEpisode, sameEpisodeStrict } from './episode-utils.js';
describe('episode title matching', () => {
it.each([
@@ -53,4 +53,10 @@ describe('episode title matching', () => {
])('rejects different titles %j and %j', (left, right) => {
expect(sameEpisode(left, right)).toBe(false);
});
it('keeps transactional matching strict when both titles expose context', () => {
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S01E06 - Visiting Ours')).toBe(true);
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S1:E6 - Another Show')).toBe(false);
expect(sameEpisodeStrict('S1:E6', 'S01E06 - Visiting Ours')).toBe(true);
});
});
@@ -44,10 +44,10 @@ describe('offline media intent background integration', () => {
expect(flushIndex).toBeGreaterThan(-1);
expect(policyIndex).toBeLessThan(canonicalIndex);
expect(canonicalIndex).toBeLessThan(flushIndex);
expect(roomData).toContain('activeLobby: !!episodeLobby');
expect(roomData).toContain('activeLobby: !!(episodeLobby || episodeSyncV2)');
expect(roomData).toContain('desynced: hcmDesynced');
expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)');
expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby');
expect(roomData).toContain('authoritativeLobby: !!(authoritativeLobby || authoritativeEpisodeSyncV2)');
});
it('clears queued room intent on failed join, leave and room switch paths', () => {
+7 -1
View File
@@ -5,7 +5,13 @@ export const MAX_LOGICAL_QUEUE_SIZE = 50;
const MEDIA_EVENTS = new Set([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK]);
const KNOWN_EVENTS = new Set(Object.values(EVENTS));
const STALE_OFFLINE_EVENTS = new Set([EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]);
const STALE_OFFLINE_EVENTS = new Set([
EVENTS.PING,
EVENTS.PONG,
EVENTS.PEER_STATUS,
EVENTS.EVENT_ACK,
EVENTS.EPISODE_SYNC_V2
]);
const FORCE_SYNC_EVENTS = new Set([EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]);
const HOST_GATED_EVENTS = new Set([
...MEDIA_EVENTS,