mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-03 22:45:21 +00:00
113 lines
4.8 KiB
JavaScript
113 lines
4.8 KiB
JavaScript
/**
|
|
* KoalaSync Episode Title Utilities
|
|
* Single source of truth — synced to content.js by build-extension.cjs.
|
|
* Keep in sync with the injection block in content.js!
|
|
*/
|
|
|
|
// The relay clamps episode titles to 100 UTF-16 code units. Clamp before the
|
|
// wire and before strict comparison as well; otherwise a valid long local title
|
|
// can never match the relay's authoritative value.
|
|
export const EPISODE_WIRE_TITLE_LENGTH = 100;
|
|
|
|
export function toEpisodeWireTitle(title) {
|
|
if (typeof title !== 'string' || title.length === 0) return null;
|
|
return title.substring(0, EPISODE_WIRE_TITLE_LENGTH);
|
|
}
|
|
|
|
export function extractEpisodeId(title) {
|
|
if (!title || typeof title !== 'string') return null;
|
|
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
|
|
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
|
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
|
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
|
return null;
|
|
}
|
|
|
|
export function sameEpisode(titleA, titleB) {
|
|
if (!titleA && !titleB) return true;
|
|
if (!titleA || !titleB) return false;
|
|
const idA = extractEpisodeId(titleA);
|
|
const idB = extractEpisodeId(titleB);
|
|
if (idA && idB) return idA === idB;
|
|
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) {
|
|
const wireTitleA = toEpisodeWireTitle(titleA);
|
|
const wireTitleB = toEpisodeWireTitle(titleB);
|
|
if (!sameEpisode(wireTitleA, wireTitleB)) return false;
|
|
const contextA = episodeContext(wireTitleA);
|
|
const contextB = episodeContext(wireTitleB);
|
|
if (!contextA || !contextB || contextA === contextB) return true;
|
|
// Players disagree about whether MediaSession.title contains the series,
|
|
// episode title, or both. Accept a complete token-boundary subset while
|
|
// still rejecting unrelated contextual titles for the same S/E number.
|
|
const [shorter, longer] = contextA.length <= contextB.length
|
|
? [contextA, contextB]
|
|
: [contextB, contextA];
|
|
return shorter.length >= 4 && ` ${longer} `.includes(` ${shorter} `);
|
|
}
|
|
|
|
export function createEpisodeWireIdentity(title) {
|
|
const expectedEpisodeId = extractEpisodeId(title);
|
|
const expectedTitle = toEpisodeWireTitle(title);
|
|
if (!expectedTitle || !expectedEpisodeId) return null;
|
|
return { expectedTitle, expectedEpisodeId };
|
|
}
|
|
|
|
export function sameEpisodeIdentity(localTitle, expectedTitle, expectedEpisodeId = null) {
|
|
const normalizedExpectedId = typeof expectedEpisodeId === 'string'
|
|
? expectedEpisodeId.trim().toUpperCase().substring(0, 16)
|
|
: null;
|
|
if (normalizedExpectedId && extractEpisodeId(localTitle) !== normalizedExpectedId) return false;
|
|
return sameEpisodeStrict(localTitle, expectedTitle);
|
|
}
|
|
|
|
export function createLocalEpisodeDeadline(remainingMs, fallbackMs, now = Date.now()) {
|
|
const safeFallback = Number.isFinite(fallbackMs) && fallbackMs > 0 ? fallbackMs : 0;
|
|
const safeRemaining = Number.isFinite(remainingMs)
|
|
? Math.max(0, Math.min(safeFallback, remainingMs))
|
|
: safeFallback;
|
|
return {
|
|
remainingMs: safeRemaining,
|
|
deadlineAt: now + safeRemaining
|
|
};
|
|
}
|
|
|
|
export function isEpisodeSyncV2StartContextCurrent(pending, current, now = Date.now()) {
|
|
return !!pending
|
|
&& !!current
|
|
&& now - pending.requestedAt >= 0
|
|
&& now - pending.requestedAt <= 15000
|
|
&& pending.roomId === current.roomId
|
|
&& pending.connectionGeneration === current.connectionGeneration
|
|
&& pending.targetGeneration === current.targetGeneration
|
|
&& pending.tabId === current.tabId;
|
|
}
|
|
|
|
export function matchesEpisodeSyncV2StartRejection(pending, current, rejection, now = Date.now()) {
|
|
if (!isEpisodeSyncV2StartContextCurrent(pending, current, now)) return false;
|
|
const rejectionTitle = toEpisodeWireTitle(rejection?.expectedTitle);
|
|
const rejectionEpisodeId = typeof rejection?.expectedEpisodeId === 'string'
|
|
? rejection.expectedEpisodeId.trim().toUpperCase().substring(0, 16)
|
|
: extractEpisodeId(rejectionTitle);
|
|
return rejectionTitle === pending.expectedTitle
|
|
&& (!rejectionEpisodeId || rejectionEpisodeId === pending.expectedEpisodeId);
|
|
}
|