127 lines
3.8 KiB
JavaScript
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.',
|
|
);
|