Capture Bristol Rovers video pass streams for personal time-shifting
Verified end to end against a live match: 1080p H.264/AAC MPEG-TS at ~5.2 Mbit/s, recorded while the browser played the same stream. Components: - tampermonkey sniffer hooks fetch/XHR at document-start, captures the manifest URL verbatim and POSTs it to the local catcher. v0.4 also traces the API chain to find which call mints the token. - catcher receives the URL and launches yt-dlp. Dedupes on the Kaltura asset id and checks downloader liveness, so a mid-match page reload is both safe and the recovery path for a dead downloader. - creds stores secrets in the OS vault rather than a gitignored file, and refuses to run on a backend that does not encrypt. Protocol notes are in the README: the stream uses a two-stage token chain (a ~25s hdnts token for the master playlist, a ~24h hdntl token for media), concurrency is unrestricted, and the live window is ~30s with no DVR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
// ==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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user