Files
stream-control/tools/stream-capture/background.js
jeanotx32 4f7b8f56ae
Some checks failed
release / build (push) Successful in 27s
release / verify-windows (push) Failing after 1m3s
Feat : page streamer
2026-08-11 20:04:08 +02:00

127 lines
3.8 KiB
JavaScript

/**
* Copie les segments média que Firefox télécharge déjà, sans les redemander.
*
* `filterResponseData` donne accès au flux de réponse d'une requête que la page
* a elle-même initiée. On lit les octets au passage et on les réécrit aussitôt
* vers la page : le lecteur continue de fonctionner normalement, et l'extension
* n'émet aucune requête vers la plateforme.
*
* Portée des permissions : le manifeste demande <all_urls> parce qu'une requête
* vers un domaine non autorisé est purement invisible pour une extension — et on
* ignore quels hôtes servent le flux d'une session authentifiée. Le filtrage
* réel est ici : seules les URL de média sont lues, et rien n'est transmis
* ailleurs qu'à 127.0.0.1.
*/
const RECEIVER = 'http://127.0.0.1:8099/ingest';
/** Playlists et segments. Tout le reste est ignoré sans être lu. */
const MEDIA_PATTERN = /\.(m3u8|m4s|mp4|ts)(\?|$)/i;
let copied = 0;
let failed = 0;
let receiverWarned = false;
/** Hôtes ayant servi du média : sert à identifier le CDN réellement utilisé. */
const seenHosts = new Map();
function updateBadge() {
const text = failed ? `${copied}/${failed}` : String(copied);
browser.browserAction.setBadgeText({ text: copied || failed ? text : '' });
browser.browserAction.setBadgeBackgroundColor({ color: failed ? '#b91c1c' : '#2563eb' });
}
function noteHost(url) {
try {
const { host } = new URL(url);
const count = (seenHosts.get(host) ?? 0) + 1;
seenHosts.set(host, count);
if (count === 1) {
console.log(`stream-capture : média servi par ${host}`);
}
} catch {
/* URL exotique : sans importance pour le diagnostic */
}
}
async function forward(url, chunks) {
try {
const blob = new Blob(chunks);
const body = await blob.arrayBuffer();
const response = await fetch(RECEIVER, {
method: 'POST',
headers: { 'content-type': 'application/octet-stream', 'x-source-url': url },
body,
});
if (!response.ok) throw new Error(`récepteur : HTTP ${response.status}`);
copied += 1;
} catch (err) {
failed += 1;
// Récepteur non lancé : on n'interrompt surtout pas la lecture pour autant.
if (!receiverWarned) {
receiverWarned = true;
console.error(
`stream-capture : le récepteur ne répond pas sur ${RECEIVER}` +
'lance « node receiver.mjs » sur cette machine.',
err,
);
}
}
updateBadge();
}
browser.webRequest.onBeforeRequest.addListener(
(details) => {
if (!MEDIA_PATTERN.test(details.url)) return {};
noteHost(details.url);
let filter;
try {
filter = browser.webRequest.filterResponseData(details.requestId);
} catch (err) {
// Requête non filtrable (cache, redirection…) : la lecture continue.
console.warn('stream-capture : filtrage impossible pour', details.url, err);
return {};
}
const chunks = [];
filter.ondata = (event) => {
chunks.push(event.data);
// Réécriture immédiate : la page reçoit son flux inchangé.
filter.write(event.data);
};
filter.onstop = () => {
filter.disconnect();
if (chunks.length) void forward(details.url, chunks);
};
filter.onerror = () => {
console.warn('stream-capture : filtre interrompu —', filter.error);
};
return {};
},
{ urls: ['<all_urls>'] },
['blocking'],
);
/** Résumé périodique, à recopier en cas de souci. */
setInterval(() => {
if (!seenHosts.size) return;
const summary = [...seenHosts.entries()]
.map(([host, count]) => `${host} (${count})`)
.join(', ');
console.log(`stream-capture : ${copied} copiés, ${failed} en échec — hôtes : ${summary}`);
}, 30_000);
console.log(
'stream-capture actif. Récepteur attendu sur ' +
RECEIVER +
'. Aucun hôte média détecté pour l\'instant.',
);