The broadcast subtitle rule treated any ko* track as Korean, so YouTube's auto-generated (자동 생성) Korean track would switch captions on. Match only human-authored tracks (kind !== 'asr', vss_id not 'a.*') and pass the full track object to setOption so YouTube selects the manual track rather than the same-languageCode ASR one. Captions stay off when only an auto track exists. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
151 lines
6.9 KiB
JavaScript
151 lines
6.9 KiB
JavaScript
// 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
|
|
// real (human-authored) Korean track is turned ON when the video offers
|
|
// one. YouTube's auto-generated (자동 생성) Korean track does NOT count and
|
|
// is left off. 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;
|
|
};
|
|
|
|
// A "real" Korean caption track is a human-authored/uploaded Korean track,
|
|
// NOT YouTube's auto-speech-recognition (자동 생성) track. ASR tracks report
|
|
// kind === 'asr' and a vss_id like 'a.ko'; manual tracks have an empty kind
|
|
// and a vss_id like '.ko'. We only ever turn captions on for a real track.
|
|
const isRealKo = (t) => !!t && /^ko/i.test(t.languageCode || '')
|
|
&& t.kind !== 'asr' && !/^a\./.test(t.vss_id || '');
|
|
|
|
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: ON only if a real (non-ASR)
|
|
// Korean track is offered, otherwise OFF.
|
|
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.some(isRealKo) ? '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 or
|
|
// swap our manual Korean track for the auto-generated one.
|
|
let curTrack = null;
|
|
try { curTrack = p.getOption('captions', 'track'); } catch {}
|
|
const curLc = (curTrack && curTrack.languageCode) || '';
|
|
if (capWant[vid] === 'ko') {
|
|
if (!isRealKo(curTrack)) { // not on the real Korean track (off, or ASR) -> set it
|
|
let tracks = []; try { tracks = p.getOption('captions', 'tracklist') || []; } catch {}
|
|
const ko = tracks.find(isRealKo);
|
|
// Pass the full track object so YouTube selects this exact manual track
|
|
// and not the same-languageCode auto-generated one.
|
|
if (ko) { try { p.setOption('captions', 'track', ko); } 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
|
|
|
|
// Broadcast-wide: when a tab enters HTML5 fullscreen (a video 'f'), hide
|
|
// Chrome's toolbar by putting THAT tab's window into Chrome-initiated
|
|
// fullscreen - xfwm4 won't hide it on HTML5 fullscreen alone, so the address
|
|
// bar would otherwise show on the broadcast. We resolve the exact window of
|
|
// the fullscreen tab (not just the first tab) and restore it on exit.
|
|
const cdp = await b.newBrowserCDPSession();
|
|
let fsWindowId = null;
|
|
const windowIdFor = async (page) => {
|
|
const s = await page.context().newCDPSession(page);
|
|
try {
|
|
const { targetInfo } = await s.send('Target.getTargetInfo');
|
|
const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId: targetInfo.targetId });
|
|
return windowId;
|
|
} finally { await s.detach().catch(() => {}); }
|
|
};
|
|
const fsTimer = setInterval(async () => {
|
|
try {
|
|
let fsPage = null;
|
|
for (const p of ctx.pages()) {
|
|
if (await p.evaluate(() => !!document.fullscreenElement).catch(() => false)) { fsPage = p; break; }
|
|
}
|
|
if (fsPage && fsWindowId === null) {
|
|
const windowId = await windowIdFor(fsPage);
|
|
await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'fullscreen' } });
|
|
fsWindowId = windowId;
|
|
} else if (!fsPage && fsWindowId !== null) {
|
|
await cdp.send('Browser.setWindowBounds', { windowId: fsWindowId, bounds: { windowState: 'normal' } });
|
|
fsWindowId = null;
|
|
}
|
|
} catch { /* best-effort */ }
|
|
}, 600);
|
|
|
|
console.log('broadcast-helper armed on', ctx.pages().length, 'tab(s)');
|
|
await new Promise((resolve) => b.on('disconnected', resolve));
|
|
clearInterval(fsTimer);
|
|
}
|
|
|
|
// 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));
|
|
}
|