Remove Disney+ DOM scraping fallback

This commit is contained in:
Timo
2026-07-02 15:08:54 +02:00
parent 076157fef1
commit 9ac8c28442
4 changed files with 47 additions and 432 deletions
+31 -374
View File
@@ -57,13 +57,7 @@
};
// --- SHARED_EVENTS_INJECT_END ---
// Suppresses native event reporting after a programmatic action.
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
// Suppresses native event reporting after a programmatic action.
@@ -96,284 +90,34 @@
if (_suppressTimers[state]) {
}
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
// from being relayed to peers as user-initiated seeks.
const MIN_SEEK_DELTA = 2.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks
const PAGE_API_SEEK_BRIDGE = 1;
let lastDisneyPlusTimelineCandidates = [];
let lastKnownDisneyPlusDuration = 0;
let lastKnownDisneyPlusScale = 1;
let lastKnownDisneyPlusStart = 0;
let lastDisneyPlusUiCurrent = null;
let lastDisneyPlusNativeAtUi = null;
// Accurate Disney+ playhead pushed by the MAIN-world page-API bridge
// (background.js installPageApiSeekBridge). The isolated content world
// can't read the page's media player directly.
let disneyPageApiTime = null;
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('message', (event) => {
if (event.source !== window) return;
const data = event.data;
if (data && data.__koalaPlayerTime === 1 && data.provider === 'disney'
&& Number.isFinite(data.position) && Number.isFinite(data.duration) && data.duration > 0) {
disneyPageApiTime = { position: data.position, duration: data.duration, at: Date.now() };
}
});
}
function hostMatchesUrl(host, url) {
const normalized = String(url || '')
.replace(/^https?:\/\//i, '')
.split('/')[0]
.toLowerCase();
return normalized && (host === normalized || host.endsWith(`.${normalized}`));
}
function matchesPlayerUrls(urls) {
const host = window.location.hostname.toLowerCase();
return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url));
}
function isDisneyPlusHost() {
return matchesPlayerUrls(['disneyplus.com']);
}
function getSeekableRange(video) {
try {
const ranges = video.seekable;
if (!ranges || ranges.length === 0) return null;
const current = video.currentTime;
let index = ranges.length - 1;
if (Number.isFinite(current)) {
for (let i = 0; i < ranges.length; i++) {
if (current >= ranges.start(i) && current <= ranges.end(i)) {
index = i;
break;
}
}
}
const start = ranges.start(index);
const end = ranges.end(index);
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
return { start, end, duration: end - start };
} catch (_e) {
return null;
}
}
function parseClockTime(value) {
const parts = String(value || '').trim().split(':').map(Number);
if (parts.length < 2 || parts.length > 3 || parts.some(n => !Number.isFinite(n))) return null;
return parts.length === 2
? parts[0] * 60 + parts[1]
: parts[0] * 3600 + parts[1] * 60 + parts[2];
}
function parseTimelineText(text) {
const matches = String(text || '').match(/\b\d{1,3}:\d{2}(?::\d{2})?\b/g);
if (!matches || matches.length < 2) return null;
const current = parseClockTime(matches[0]);
const duration = parseClockTime(matches[matches.length - 1]);
if (!Number.isFinite(current) || !Number.isFinite(duration) || duration < 60 || current > duration + 5) return null;
return { current, duration };
}
function getDisneyPlusUiTimeline() {
if (typeof document === 'undefined') return null;
const selectors = [
'[role="slider"]',
'[role="progressbar"]',
'[aria-valuenow][aria-valuemax]',
'[aria-valuetext]',
'[aria-label]',
'[data-testid]',
'[data-test]',
'[class*="time" i]',
'[class*="progress" i]',
'time',
'output',
'button',
'span',
'p',
'div'
].join(',');
const nodes = querySelectorAllShadow(selectors).slice(0, 2000);
const textParts = [];
const candidates = [];
let best = null;
let bestScore = -1;
let remainingSeconds = null;
function considerTimeline(parsed, source, weight = 0) {
if (!parsed) return;
const score = weight +
(parsed.duration >= 20 * 60 ? 200 : 0) +
(parsed.current > 0 ? 50 : 0) -
Math.abs((parsed.duration / 2) - parsed.current) / 100;
candidates.push({ ...parsed, source, score: Math.round(score) });
if (score > bestScore) {
bestScore = score;
best = parsed;
}
}
for (const node of nodes) {
const now = Number(node.getAttribute?.('aria-valuenow') ?? node.value);
const max = Number(node.getAttribute?.('aria-valuemax') ?? node.max);
if (Number.isFinite(now) && Number.isFinite(max) && max >= 60 && now >= 0 && now <= max + 5) {
considerTimeline({ current: now, duration: max }, 'aria-value', 300);
}
for (const attr of ['aria-valuetext', 'aria-label', 'title', 'data-testid', 'data-test']) {
const parsed = parseTimelineText(node.getAttribute?.(attr));
considerTimeline(parsed, attr, 200);
}
const text = String(node.textContent || '').trim();
if (text && text.length < 200) {
const parsed = parseTimelineText(text);
considerTimeline(parsed, 'text', 100);
textParts.push(text);
}
// Disney's scrubber aria-valuenow can lag several seconds behind
// real playback; a "time remaining" indicator updates live, so
// capture it and later derive current = duration - remaining.
const remClass = String(node.getAttribute?.('class') || '') + ' ' + String(node.getAttribute?.('data-testid') || '');
if (/remain/i.test(remClass)) {
const remMatch = String(node.textContent || '').match(/\d{1,3}:\d{2}(?::\d{2})?/);
const rem = remMatch ? parseClockTime(remMatch[0]) : null;
if (Number.isFinite(rem) && rem >= 0) remainingSeconds = rem;
}
}
if (best && remainingSeconds !== null && best.duration > 0) {
const liveCurrent = best.duration - remainingSeconds;
if (liveCurrent >= 0 && liveCurrent <= best.duration + 5) {
considerTimeline({ current: Math.min(liveCurrent, best.duration), duration: best.duration }, 'time-remaining', 1000);
}
}
considerTimeline(parseTimelineText(textParts.join(' ')), 'combined-text', 25);
lastDisneyPlusTimelineCandidates = candidates
.sort((a, b) => b.score - a.score)
.slice(0, 8)
.map(({ source, current, duration }) => ({ source, current, duration }));
return best;
}
function getElementLabel(el) {
return [
el.getAttribute?.('aria-label'),
el.getAttribute?.('title'),
el.getAttribute?.('data-testid'),
el.getAttribute?.('data-test'),
el.textContent
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
function getDisneyPlusSeekButtonLabels() {
if (!isDisneyPlusHost() || typeof document === 'undefined') return [];
return querySelectorAllShadow('button,[role="button"]')
clearTimeout(_suppressTimers[state]);
.filter(Boolean)
.slice(0, 20);
delete _suppressTimers[state];
}
}
const backward = delta < 0;
const candidates = querySelectorAllShadow('button,[role="button"]');
const backRe = /rewind|backward|back\b|zurück|zurueck|rück|rueck|retour|recul|retroced|atrás|voltar|indietro/i;
const forwardRe = /forward|ahead|skip|vorwärts|vorwaerts|vorsp|weiter|avancer|adelant|avançar|avancar|avanti/i;
const timeRe = /\b(5|10|15|30)\b|sec|sek|second/i;
const button = candidates.find(btn => {
const label = getElementLabel(btn);
const matchesDirection = backward ? backRe.test(label) : forwardRe.test(label);
return matchesDirection && timeRe.test(label);
}) || candidates.find(btn => {
const label = getElementLabel(btn);
return backward ? backRe.test(label) : forwardRe.test(label);
});
if (button && typeof button.click === 'function') {
// Disney offers no absolute seek on its blob <video>, so we click its
// relative skip button. Derive the step from the button's own label
// (usually 10s; some UIs 5/15/30) and issue enough clicks to cover the
// full delta. The previous 120s cap truncated any larger seek, which
// broke absolute-position sync (e.g. seeking to 5:00 from far away).
const stepMatch = getElementLabel(button).match(/\b(5|10|15|30)\b/);
const step = stepMatch ? Number(stepMatch[1]) : 10;
const clicks = Math.max(1, Math.min(90, Math.round(Math.abs(delta) / step)));
for (let i = 0; i < clicks; i++) {
setTimeout(() => button.click(), i * 60);
}
return true;
}
// Fallback: Simulate ArrowLeft / ArrowRight keyboard events
const clicks = Math.max(1, Math.min(12, Math.round(Math.abs(delta) / 10)));
const eventInit = {
key: backward ? 'ArrowLeft' : 'ArrowRight',
code: backward ? 'ArrowLeft' : 'ArrowRight',
keyCode: backward ? 37 : 39,
which: backward ? 37 : 39,
bubbles: true,
cancelable: true,
view: window
};
const target = document.querySelector('.hive-video') || document.activeElement || document.body;
for (let i = 0; i < clicks; i++) {
setTimeout(() => {
target.dispatchEvent(new window.KeyboardEvent('keydown', eventInit));
target.dispatchEvent(new window.KeyboardEvent('keyup', eventInit));
}, i * 60);
}
return true;
}
function getDisneyPlusTimeline(video) {
if (!isDisneyPlusHost()) return null;
// Preferred: exact playhead/duration from the page media player,
// relayed by the MAIN-world bridge. Authoritative while fresh.
if (disneyPageApiTime && (Date.now() - disneyPageApiTime.at) < 2000 && disneyPageApiTime.duration > 0) {
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
const range = getSeekableRange(video);
const current = video.currentTime;
const ui = getDisneyPlusUiTimeline();
if (ui) {
lastKnownDisneyPlusDuration = ui.duration;
let nativeScale = 1;
if (range && range.duration > ui.duration * 1.2) {
nativeScale = range.duration / ui.duration;
} else if (Number.isFinite(current) && ui.current > 1 && current > ui.current * 1.5) {
nativeScale = current / ui.current;
}
const nativeStart = Number.isFinite(current)
? current - ui.current * nativeScale
: (range ? range.start : 0);
lastKnownDisneyPlusScale = nativeScale;
lastKnownDisneyPlusStart = nativeStart;
// from being relayed to peers as user-initiated seeks.
const MIN_SEEK_DELTA = 2.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks
const PAGE_API_SEEK_BRIDGE = 1;
// Accurate Disney+ playhead pushed by the MAIN-world page-API bridge
// (background.js installPageApiSeekBridge). The isolated content world
// can't read the page's media player directly.
let disneyPageApiTime = null;
@@ -419,7 +163,7 @@
// Site-specific player exceptions live here. The default HTML5 path stays below.
function getSiteQuirkAdapters() {
return [{
matches() { return matchesPlayerUrls(this.urls); },
name: 'disneyplus-page-api',
key: 'disneyPlus',
urls: ['disneyplus.com'],
matches() { return matchesPlayerUrls(this.urls); },
@@ -427,21 +171,9 @@
getDebug(video) {
return {
name: this.name,
timeline: getDisneyPlusTimeline(video),
timelineCandidates: lastDisneyPlusTimelineCandidates,
seekButtons: getDisneyPlusSeekButtonLabels()
};
}
}];
}
function getActiveSiteQuirk() {
return getSiteQuirkAdapters().find(adapter => adapter.matches()) || null;
}
key: 'disneyPlus',
urls: this.urls,
return adapter ? adapter.getTimeline(video) : null;
timeline: getDisneyPlusTimeline(video)
};
}
}];
@@ -1501,11 +1233,10 @@
function applyAudioSettings(videoEl, settings) {
const mergedSettings = mergeAudioSettings(settings);
if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
function applyAudioSettings(videoEl, settings) {
const mergedSettings = mergeAudioSettings(settings);
if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
applyAudioBypass(videoEl);
@@ -1513,10 +1244,9 @@
return;
}
const chain = setupAudioChain(videoEl);
const chain = setupAudioChain(videoEl);
if (!chain) return;
@@ -1733,80 +1463,7 @@
const video = findVideo();
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& video.currentTime < 5 && video.readyState >= 1) {
// Match! Pause at start and report ready.
if (!video.paused) {
_setSuppress('paused');
video.pause();
}
stopLobbyPoll();
chrome.runtime.sendMessage({
type: 'EPISODE_READY_LOCAL',
payload: { title: currentTitle }
}).catch(() => {});
reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success');
return true;
}
return false;
}
function startLobbyPoll(expectedTitle) {
stopLobbyPoll();
_pendingLobbyTitle = expectedTitle;
// NOTE: Do NOT pause here. Three callers reach this function:
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
// 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing
// would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady()
// only once their title actually matches.
// 3. CONTENT_BOOT recovery: same reasoning as (2).
// Check immediately
if (checkAndReportLobbyReady(expectedTitle)) return;
// Poll every 2 seconds — no log spam, internal only
lobbyPollTimer = setInterval(() => {
checkAndReportLobbyReady(expectedTitle);
}, 2000);
const currentTitle = getMediaTitle();
-6
View File
@@ -700,12 +700,6 @@
<button id="remoteSeekForward" class="secondary" style="flex:1; font-size:12px;">+30s</button>
</div>
<button id="remoteSeekFiveMin" class="secondary" style="width:100%; font-size:12px; margin-bottom:15px;">Seek 5:00</button>
<div style="margin-top: 15px;">
<label style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px;">DOM Timestamps (Scraped)</label>
<div class="info-card" id="devtoolsDomScraperList" style="font-size:10px; max-height:100px; overflow-y:auto; font-family:monospace; line-height:1.2; padding:6px; background:rgba(0,0,0,0.2); white-space:pre-wrap; border: 1px solid #334155;">
No timestamps scraped.
</div>
</div>
<div style="font-size:10px; color:var(--text-muted); text-align:center; margin-top:10px; opacity:0.7;">
Build: <span id="buildIdentifier">__BUILD_TIMESTAMP__</span>
</div>
-7
View File
@@ -2292,13 +2292,6 @@ function refreshDebugInfo() {
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons.slice(0, 4).join(' | ') : '';
if (buttons) addField(`${label} Buttons`, buttons);
}
const domScraperEl = document.getElementById('devtoolsDomScraperList');
if (domScraperEl && state.scrapedTimestamps) {
domScraperEl.textContent = state.scrapedTimestamps.length > 0
? state.scrapedTimestamps.join('\n')
: 'No timestamps scraped.';
}
addSection('Properties');
addField('Seeking', String(state.seeking));
addField('Ended', String(state.ended));
+16 -45
View File
@@ -77,38 +77,15 @@ function makeDocument(nodes = []) {
};
}
function makeNode(attrs = {}, textContent = '') {
return {
textContent,
value: attrs.value,
max: attrs.max,
clicked: 0,
click() { this.clicked += 1; },
getAttribute(name) { return attrs[name] ?? null; }
};
}
function loadTimelineFns(hostname, document = makeDocument()) {
function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null) {
const disneyPageApiTime = pageApiTime
? `let disneyPageApiTime = { position: ${pageApiTime.position}, duration: ${pageApiTime.duration}, at: Date.now() - ${pageApiTime.ageMs || 0} };`
: 'let disneyPageApiTime = null;';
return Function('window', 'document', [
'let lastDisneyPlusTimelineCandidates = [];',
'let lastKnownDisneyPlusDuration = 0;',
'let lastKnownDisneyPlusScale = 1;',
'let lastKnownDisneyPlusStart = 0;',
'let lastDisneyPlusUiCurrent = null;',
'let lastDisneyPlusNativeAtUi = null;',
'let disneyPageApiTime = null;',
extractFunction('scanShadowDom', source),
extractFunction('querySelectorAllShadow', source),
disneyPageApiTime,
extractFunction('hostMatchesUrl', source),
extractFunction('matchesPlayerUrls', source),
extractFunction('isDisneyPlusHost', source),
extractFunction('getSeekableRange', source),
extractFunction('parseClockTime', source),
extractFunction('parseTimelineText', source),
extractFunction('getDisneyPlusUiTimeline', source),
extractFunction('getElementLabel', source),
extractFunction('getDisneyPlusSeekButtonLabels', source),
extractFunction('clickDisneyPlusRelativeSeek', source),
extractFunction('getDisneyPlusTimeline', source),
extractFunction('getSiteQuirkAdapters', source),
extractFunction('getActiveSiteQuirk', source),
@@ -117,7 +94,7 @@ function loadTimelineFns(hostname, document = makeDocument()) {
extractFunction('getSyncCurrentTime', source),
extractFunction('getSyncDuration', source),
extractFunction('toNativeSeekTime', source),
'return { getActiveSiteQuirk, getSyncCurrentTime, getSyncDuration, toNativeSeekTime, clickDisneyPlusRelativeSeek };'
'return { getActiveSiteQuirk, getSyncCurrentTime, getSyncDuration, toNativeSeekTime };'
].join('\n'))({ location: { hostname } }, document);
}
@@ -131,11 +108,11 @@ function loadPlayerFixFns(hostname) {
].join('\n'))({ location: { hostname } });
}
const disneyUiDocument = makeDocument([
makeNode({ 'aria-valuetext': '0:09 / 180:00' })
]);
const disneyFns = loadTimelineFns('www.disneyplus.com', disneyUiDocument);
assert.equal(disneyFns.getActiveSiteQuirk().name, 'disneyplus-timeline-and-buttons');
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
position: 9,
duration: 10800
});
assert.equal(disneyFns.getActiveSiteQuirk().name, 'disneyplus-page-api');
assert.deepEqual(disneyFns.getActiveSiteQuirk().urls, ['disneyplus.com']);
const disneyVideo = makeVideo('disney-offset', 1920, 1080, {
currentTime: 29,
@@ -144,17 +121,17 @@ const disneyVideo = makeVideo('disney-offset', 1920, 1080, {
});
assert.equal(disneyFns.getSyncCurrentTime(disneyVideo), 9);
assert.equal(disneyFns.getSyncDuration(disneyVideo), 10800);
assert.equal(disneyFns.toNativeSeekTime(disneyVideo, 39), 119);
assert.equal(disneyFns.toNativeSeekTime(disneyVideo, 39), 39);
const disneySeekableFallbackFns = loadTimelineFns('www.disneyplus.com');
const disneyNoPageApiFns = loadTimelineFns('www.disneyplus.com');
const disneyOffsetVideo = makeVideo('disney-offset', 1920, 1080, {
currentTime: 29,
duration: 0,
seekable: makeSeekable([[20, 10820]])
});
assert.equal(disneySeekableFallbackFns.getSyncCurrentTime(disneyOffsetVideo), 9);
assert.equal(disneySeekableFallbackFns.getSyncDuration(disneyOffsetVideo), 10800);
assert.equal(disneySeekableFallbackFns.toNativeSeekTime(disneyOffsetVideo, 39), 59);
assert.equal(disneyNoPageApiFns.getSyncCurrentTime(disneyOffsetVideo), 29);
assert.equal(disneyNoPageApiFns.getSyncDuration(disneyOffsetVideo), 0);
assert.equal(disneyNoPageApiFns.toNativeSeekTime(disneyOffsetVideo, 39), 39);
const genericFns = loadTimelineFns('example.com');
assert.equal(genericFns.getActiveSiteQuirk(), null);
@@ -162,12 +139,6 @@ assert.equal(genericFns.getSyncCurrentTime(disneyVideo), 29);
assert.equal(genericFns.getSyncDuration(disneyVideo), 0);
assert.equal(genericFns.toNativeSeekTime(disneyVideo, 39), 39);
const backButton = makeNode({ 'aria-label': '10 Sekunden zurück' });
const forwardButton = makeNode({ 'aria-label': '10 Sekunden vorspulen' });
const disneyButtonFns = loadTimelineFns('www.disneyplus.com', makeDocument([backButton, forwardButton]));
assert.equal(disneyButtonFns.clickDisneyPlusRelativeSeek(-30), true);
assert.equal(disneyButtonFns.clickDisneyPlusRelativeSeek(30), true);
const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);