fix(stream-test): restore audio after ads, enforce subtitle rule broadcast-wide, commit the 60fps MV path
Addresses review of the ad/subtitle work (the ad-skip.mjs -> broadcast-helper.mjs rename's other half; the prior commit only recorded the deletion): - ad mute leak: the ad-skipper muted during an ad but never un-muted, so the main video stayed silent after the first ad. Save the pre-ad muted/playbackRate and restore them when the ad ends (verified: muted false -> true -> false). - captions were only applied once when scenario.mjs ran, not for the whole broadcast. The persistent helper now applies the rule (OFF by default, Korean ON if offered) per video and ENFORCES it every tick - one-shot did not hold because YouTube silently re-enabled captions (verified it stays off across 8s). - ad-skip + captions merged into broadcast-helper.mjs (one CDP process). - the 60fps MV test now lives in the repo: scenario.mjs gains MV_QUERY (search + auto-pick the first >=60fps result) and WATCH_SECONDS, plus the fullscreen-toolbar-hide fix. The broadcast runs via the committed stream-hold.ts (audio + keepalive), not an out-of-repo copy. - document the test env vars (CDP_PORT, HOLD_MS, TEST_*, MV_QUERY, WATCH_SECONDS). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
102
bot/scripts/stream-test/broadcast-helper.mjs
Normal file
102
bot/scripts/stream-test/broadcast-helper.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
// Persistent broadcast browser helper. Connects over CDP and injects one
|
||||
// watcher into every tab (current and future) that:
|
||||
// 1. Auto-skips YouTube ads - clicks "Skip ad" the instant it appears, closes
|
||||
// overlay ads, and fast-forwards unskippable ads (seek-to-end + 16x + mute)
|
||||
// so they clear in ~1s. The pre-ad muted/playbackRate are SAVED and
|
||||
// RESTORED when the ad ends, so the main video is never left muted/fast.
|
||||
// 2. Applies the subtitle rule per video: captions OFF by default, but a
|
||||
// Korean track is turned ON when the video offers one. Runs once per video.
|
||||
// Self-contained: no extension, no network/hosts changes. Reconnects across
|
||||
// Chrome restarts.
|
||||
//
|
||||
// node bot/scripts/stream-test/broadcast-helper.mjs (CDP_PORT, default 9222)
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const CDP = process.env.CDP_PORT || '9222';
|
||||
|
||||
const WATCH = `(() => {
|
||||
if (window.__ytBroadcast) return; window.__ytBroadcast = true;
|
||||
let adSaved = null; // {muted, rate} captured when an ad starts
|
||||
const capWant = {}; // videoId -> 'ko' | 'off' (desired, decided once)
|
||||
const capTries = {}; // videoId -> attempts to read the tracklist
|
||||
|
||||
const adTick = () => {
|
||||
const p = document.getElementById('movie_player');
|
||||
const adShowing = !!(p && p.classList && p.classList.contains('ad-showing'));
|
||||
const v = document.querySelector('video');
|
||||
const skip = document.querySelector(
|
||||
'.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button, .ytp-ad-skip-button-container button');
|
||||
if (skip) skip.click();
|
||||
document.querySelectorAll('.ytp-ad-overlay-close-button, .ytp-ad-overlay-close-container button').forEach((b) => b.click());
|
||||
if (adShowing) {
|
||||
if (adSaved === null && v) adSaved = { muted: v.muted, rate: v.playbackRate };
|
||||
if (v) {
|
||||
v.muted = true;
|
||||
if (isFinite(v.duration) && v.duration > 0) { try { v.currentTime = v.duration; } catch {} }
|
||||
v.playbackRate = 16;
|
||||
}
|
||||
} else if (adSaved !== null && v) {
|
||||
// ad finished: restore exactly what the user had before the ad
|
||||
v.muted = adSaved.muted;
|
||||
v.playbackRate = adSaved.rate;
|
||||
adSaved = null;
|
||||
}
|
||||
return adShowing;
|
||||
};
|
||||
|
||||
const capTick = (adShowing) => {
|
||||
if (adShowing) return; // don't touch captions while an ad plays
|
||||
const p = document.getElementById('movie_player');
|
||||
if (!p || !p.getOption || !p.getVideoData) return;
|
||||
const vid = p.getVideoData().video_id;
|
||||
if (!vid) return;
|
||||
// Decide the desired state once per video (off, or Korean if offered).
|
||||
if (capWant[vid] === undefined) {
|
||||
capTries[vid] = (capTries[vid] || 0) + 1;
|
||||
let tracks = [];
|
||||
try { p.loadModule('captions'); tracks = p.getOption('captions', 'tracklist') || []; } catch {}
|
||||
if (tracks.length) capWant[vid] = tracks.find((t) => /^ko/i.test(t.languageCode || '')) ? 'ko' : 'off';
|
||||
else if (capTries[vid] > 16) capWant[vid] = 'off'; // no tracks: keep it off
|
||||
else return; // tracklist not ready yet
|
||||
}
|
||||
// Enforce it every tick so YouTube cannot silently re-enable captions.
|
||||
let curLc = '';
|
||||
try { const c = p.getOption('captions', 'track'); curLc = (c && c.languageCode) || ''; } catch {}
|
||||
if (capWant[vid] === 'ko') {
|
||||
if (!/^ko/i.test(curLc)) {
|
||||
let tracks = []; try { tracks = p.getOption('captions', 'tracklist') || []; } catch {}
|
||||
const ko = tracks.find((t) => /^ko/i.test(t.languageCode || ''));
|
||||
if (ko) { try { p.setOption('captions', 'track', { languageCode: ko.languageCode }); } catch {} }
|
||||
}
|
||||
} else if (curLc) { // want off but a track is on -> turn it off
|
||||
try { p.setOption('captions', 'track', {}); } catch {}
|
||||
try { p.unloadModule('captions'); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
let adShowing = false;
|
||||
try { adShowing = adTick(); } catch {}
|
||||
try { capTick(adShowing); } catch {}
|
||||
}, 250);
|
||||
})();`;
|
||||
|
||||
async function arm(page) {
|
||||
try { await page.addInitScript(WATCH); } catch {} // survives navigations
|
||||
try { await page.evaluate(WATCH); } catch {} // arm the already-loaded doc
|
||||
}
|
||||
|
||||
async function session() {
|
||||
const b = await chromium.connectOverCDP(`http://localhost:${CDP}`);
|
||||
const ctx = b.contexts()[0];
|
||||
for (const p of ctx.pages()) await arm(p);
|
||||
ctx.on('page', arm); // new tabs
|
||||
console.log('broadcast-helper armed on', ctx.pages().length, 'tab(s)');
|
||||
await new Promise((resolve) => b.on('disconnected', resolve));
|
||||
}
|
||||
|
||||
// Reconnect across Chrome restarts so the broadcast stays ad-free.
|
||||
while (true) {
|
||||
try { await session(); } catch { /* CDP down */ }
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
Reference in New Issue
Block a user