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:
Mikkeli Matlock
2026-08-29 23:57:05 +09:00
commit 21113bd6ba
6 changed files with 711 additions and 0 deletions
+191
View File
@@ -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);
}
}
})();