/** * 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 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: [''] }, ['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.', );