Files

262 lines
9.6 KiB
JavaScript
Raw Permalink Normal View History

// ==UserScript==
// @name BRFC stream sniffer
// @namespace local.brfc.archive
// @version 0.4
// @description Capture the manifest URL the player requests, and trace the API call that mints its token.
// @match *://*.bristolrovers.co.uk/*
// @match *://*.streamamg.com/*
// @match *://*.streamamg.co.uk/*
// @match *://*.kaltura.com/*
// @match *://*.kaltura.co.uk/*
// @match *://*.akamaized.net/*
// @match *://*.akamaihd.net/*
// @noframes false
// @run-at document-start
// @grant GM_xmlhttpRequest
// @connect 127.0.0.1
// ==/UserScript==
(function () {
'use strict';
const CATCHER = 'http://127.0.0.1:8787/stream';
const TRACER = 'http://127.0.0.1:8787/trace';
// Discovery mode: record the request/response chain that produces a stream
// URL, so the token mint can be replayed without a GUI browser. Set false
// once the endpoint is known -- it writes a lot.
const TRACE = true;
// Start playback without a human. The player needs a click, but the browser
// only has to trigger the manifest fetch -- ffmpeg records the real audio
// independently, so playing MUTED costs nothing and muted autoplay is
// permitted by Firefox policy where audible autoplay is not.
const AUTOPLAY = true;
const AUTOPLAY_TIMEOUT_MS = 180000; // pre-match margin is long; keep trying
const IDLE_OPACITY = 0.063; // 16/255
const MAX_BODY = 20000; // never log a whole media segment
console.log('[brfc-sniffer] v0.4 active in frame:', location.href);
const seen = new Map();
const isManifest = u => typeof u === 'string' && /\.m3u8(\?|$)/i.test(u);
// Media and assets are noise. Everything else is a mint-call candidate.
const isNoise = u => /\.(ts|mp4|m4s|aac|jpe?g|png|gif|webp|svg|woff2?|css)(\?|$)/i.test(u);
function post(endpoint, payload) {
try {
GM_xmlhttpRequest({
method: 'POST',
url: endpoint,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(payload),
onerror: () => {}
});
} catch (e) { /* catcher not running; never break the page */ }
}
function record(url, via) {
let abs = url;
if (!/^https?:/i.test(url)) {
try { abs = new URL(url, location.href).href; } catch (e) { /* keep raw */ }
}
if (seen.has(abs)) return;
seen.set(abs, { url: abs, via });
console.log('[brfc-sniffer]', via, abs);
render();
post(CATCHER, {
url: abs, via, page: location.href,
userAgent: navigator.userAgent, ts: new Date().toISOString()
});
}
// The mint call is identified by what it RETURNS, not by its name. Any
// response mentioning m3u8 is a candidate, whatever the endpoint is called.
function trace(entry, bodyText) {
if (!TRACE) return;
const mints = !!bodyText && /m3u8/i.test(bodyText);
if (!mints && !/api|token|entitle|session|auth|manifest|playback|licen/i.test(entry.url)) return;
entry.mintsManifest = mints;
entry.body = bodyText ? bodyText.slice(0, MAX_BODY) : null;
entry.page = location.href;
entry.ts = new Date().toISOString();
if (mints) console.log('[brfc-sniffer] *** MINT CALL ***', entry.method, entry.url);
post(TRACER, entry);
}
// --- fetch ----------------------------------------------------------------
const _fetch = window.fetch;
if (_fetch) {
window.fetch = function (input, init) {
let url = null, method = 'GET';
try {
url = (typeof input === 'string') ? input : (input && input.url) || null;
method = (init && init.method) || (input && input.method) || 'GET';
if (isManifest(url)) record(url, 'fetch');
} catch (e) { /* ignore */ }
const p = _fetch.apply(this, arguments);
if (TRACE && url && !isNoise(url)) {
p.then(resp => {
try {
const reqBody = (init && typeof init.body === 'string')
? init.body.slice(0, MAX_BODY) : null;
resp.clone().text().then(
t => trace({ via: 'fetch', method, url, status: resp.status, reqBody }, t),
() => {}
);
} catch (e) { /* ignore */ }
}, () => {});
}
return p;
};
}
// --- XHR ------------------------------------------------------------------
const _open = XMLHttpRequest.prototype.open;
const _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
try {
this.__brfc = { method: method, url: url };
if (isManifest(url)) record(url, 'xhr');
} catch (e) { /* ignore */ }
return _open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
try {
const info = this.__brfc;
if (TRACE && info && info.url && !isNoise(info.url)) {
const reqBody = (typeof body === 'string') ? body.slice(0, MAX_BODY) : null;
this.addEventListener('load', () => {
let text = null;
try {
if (this.responseType === '' || this.responseType === 'text') {
text = this.responseText;
}
} catch (e) { /* binary response */ }
trace({ via: 'xhr', method: info.method, url: info.url,
status: this.status, reqBody: reqBody }, text);
});
}
} catch (e) { /* ignore */ }
return _send.apply(this, arguments);
};
// --- <video src> ----------------------------------------------------------
const _setAttr = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
try {
if (/^src$/i.test(name) && isManifest(value)) record(value, 'attr');
} catch (e) { /* ignore */ }
return _setAttr.apply(this, arguments);
};
// --- panel ----------------------------------------------------------------
let panel;
function render() {
if (window.top !== window.self) return;
if (!panel) {
panel = document.createElement('div');
panel.style.cssText = [
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:2147483647',
'max-width:min(560px,90vw)', 'max-height:40vh', 'overflow:auto',
'background:#101418', 'color:#e6edf3',
'font:12px/1.45 ui-monospace,Menlo,Consolas,monospace',
'border:1px solid #2b333c', 'border-radius:8px', 'padding:10px 12px',
'box-shadow:0 8px 24px rgba(0,0,0,.45)',
'opacity:' + IDLE_OPACITY,
'transition:opacity .18s ease'
].join(';');
panel.addEventListener('mouseenter', () => { panel.style.opacity = '1'; });
panel.addEventListener('mouseleave', () => { panel.style.opacity = String(IDLE_OPACITY); });
(document.body || document.documentElement).appendChild(panel);
}
panel.innerHTML = '<b style="color:#5ac8fa">BRFC sniffer v0.4</b> '
+ seen.size + ' manifest(s)'
+ (TRACE ? ' <span style="color:#e3b341">[tracing]</span>' : '') + '<br>';
for (const rec of seen.values()) {
const row = document.createElement('div');
row.style.cssText = 'margin-top:8px;word-break:break-all;'
+ 'border-top:1px solid #2b333c;padding-top:6px';
const btn = document.createElement('button');
btn.textContent = 'copy';
btn.style.cssText = 'margin-right:8px;cursor:pointer;background:#1f6feb;'
+ 'color:#fff;border:0;border-radius:4px;padding:2px 8px';
btn.onclick = () => navigator.clipboard.writeText(rec.url).then(() => {
btn.textContent = 'copied';
setTimeout(() => { btn.textContent = 'copy'; }, 1200);
});
row.appendChild(btn);
row.appendChild(document.createTextNode('[' + rec.via + '] ' + rec.url));
panel.appendChild(row);
}
}
// --- autoplay -------------------------------------------------------------
// Runs in whichever frame owns the <video>, which may be the player iframe.
function findVideo() {
const v = document.querySelector('video');
return (v && v.isConnected) ? v : null;
}
function clickPlayButton() {
const sels = [
'.bmpui-ui-hugeplaybacktogglebutton', // Bitmovin centre button
'.bmpui-ui-playbacktogglebutton', // Bitmovin control bar
'button[aria-label*="play" i]',
'[class*="play-button"]', '[class*="playButton"]'
];
for (const s of sels) {
const el = document.querySelector(s);
if (el && el.offsetParent !== null) { // must be visible to be real
el.click();
console.log('[brfc-sniffer] autoplay: clicked', s);
return true;
}
}
return false;
}
function startAutoplay() {
if (!AUTOPLAY) return;
const deadline = Date.now() + AUTOPLAY_TIMEOUT_MS;
const timer = setInterval(() => {
const v = findVideo();
// Done once something is actually playing. Critically, we must stop
// here: the play control is a TOGGLE, so clicking it again would pause
// playback and silently end the recording.
if (v && !v.paused && !v.ended && v.readyState > 2) {
console.log('[brfc-sniffer] autoplay: playing, standing down');
clearInterval(timer);
return;
}
if (Date.now() > deadline) {
console.log('[brfc-sniffer] autoplay: gave up after',
AUTOPLAY_TIMEOUT_MS / 1000, 's');
clearInterval(timer);
return;
}
if (v) {
v.muted = true; // audible autoplay is blocked; muted is not
const p = v.play();
if (p && p.catch) p.catch(() => { clickPlayButton(); });
} else {
clickPlayButton(); // player chrome may exist before <video>
}
}, 2000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startAutoplay);
} else {
startAutoplay();
}
})();