diff --git a/tools/stream-capture/README.md b/tools/stream-capture/README.md new file mode 100644 index 0000000..3e3d898 --- /dev/null +++ b/tools/stream-capture/README.md @@ -0,0 +1,92 @@ +# Stream Capture — banc d'essai + +Copie les segments média que **ton** Firefox, connecté à **ton** compte, télécharge +déjà. L'extension ne redemande rien à la plateforme : elle lit le flux de réponse +au passage et le réécrit aussitôt vers la page. + +Objectif : répondre à une question factuelle avant d'investir dans l'intégration — +**les octets reçus sont-ils du média directement exploitable ?** + +## Marche à suivre + +### 1. Lancer le récepteur + +Sur la VM, depuis ce dossier : + +```bash +node receiver.mjs +``` + +Il écoute sur `127.0.0.1:8099` et crée un dossier de session sous `./captures/`. + +### 2. Charger l'extension dans Firefox + +1. `about:debugging#/runtime/this-firefox` +2. **Charger un module temporaire…** +3. Sélectionner le fichier `manifest.json` de ce dossier + +L'extension reste active jusqu'à la fermeture de Firefox. Un compteur apparaît sur +son icône à mesure que les segments sont copiés (en rouge si le récepteur ne +répond pas). + +### 3. Lancer la lecture + +Ouvre le profil du streamer dans un onglet, connecté à ton compte, et laisse jouer +30 secondes. Le récepteur affiche les segments au fil de l'eau. + +### 4. Obtenir le verdict + +`Ctrl-C` dans le terminal du récepteur. Il assemble ce qu'il a reçu et analyse : + +``` + Manifeste réel : oui ← le direct, pas la boucle publicitaire + Leurre publicitaire détecté : non + Tags MOUFLON : présents + + ✓ ffprobe lit le fichier — les segments sont du média exploitable : + format_name=mov,mp4,m4a + codec_type=video / codec_name=h264 / 1920x1080 +``` + +`ffprobe` est requis pour le verdict : `sudo apt install ffmpeg`. + +## Interpréter le résultat + +| Sortie | Signification | +| --- | --- | +| ✓ ffprobe lit le fichier | Les segments sont du fMP4 standard. **La capture directe est viable**, l'intégration à l'application a du sens | +| ✗ ffprobe ne reconnaît pas | Soit l'assemblage est incomplet (init manquant), soit les segments subissent une transformation côté lecteur | +| Leurre publicitaire : oui | La session n'a pas servi le direct — vérifie que tu es bien connecté dans cet onglet | +| Rien n'a été capturé | Extension non chargée, ou lecture jamais démarrée | + +Dans le second cas, **envoie-moi la sortie complète**. Si les segments sont +transformés par le lecteur, écrire l'inverse de cette transformation reviendrait à +contourner une mesure de protection, et je m'arrêterai là — mais la distinction se +fait sur ce verdict, pas sur une supposition. + +## Ce que fait exactement l'extension + +``` +requête initiée par la page + │ + ├──► filterResponseData : on lit les octets qui arrivent + │ │ + │ ├──► filter.write(...) → la page reçoit son flux, inchangé + │ └──► POST 127.0.0.1:8099 → copie locale + │ + └──► lecture normale, aucune requête supplémentaire vers la plateforme +``` + +Elle n'observe que `*.doppiocdn.com` (le CDN média), n'envoie rien ailleurs qu'en +`127.0.0.1`, et n'a aucune permission de lecture de page ni de cookies. + +## Limites de ce banc d'essai + +- Aucune intégration à l'application : ni bouton, ni dossier de destination + configurable, ni découpage par session d'enregistrement. C'est délibéré — on + valide la faisabilité avant de construire la plomberie. +- L'assemblage concatène `init` puis les segments dans l'ordre d'arrivée. Si + plusieurs qualités sont téléchargées en parallèle, le fichier mélangera les + pistes : force une seule qualité dans le lecteur pour le test. +- L'extension étant chargée en temporaire, elle disparaît au redémarrage de + Firefox. diff --git a/tools/stream-capture/background.js b/tools/stream-capture/background.js new file mode 100644 index 0000000..c44c8fe --- /dev/null +++ b/tools/stream-capture/background.js @@ -0,0 +1,79 @@ +/** + * 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. + * + * C'est la différence avec un script qui irait rechercher le flux : ici on ne + * fait que conserver ce que la session authentifiée a légitimement reçu. + */ + +const RECEIVER = 'http://127.0.0.1:8099/ingest'; + +// Playlists et segments servis par le CDN média de la plateforme. +const MEDIA_PATTERN = /\.(m3u8|m4s|mp4|ts)(\?|$)/i; + +let copied = 0; +let failed = 0; + +function updateBadge() { + const text = failed ? `${copied}/${failed}!` : String(copied); + browser.browserAction.setBadgeText({ text: copied || failed ? text : '' }); + browser.browserAction.setBadgeBackgroundColor({ color: failed ? '#b91c1c' : '#2563eb' }); +} + +async function forward(url, chunks) { + try { + const blob = new Blob(chunks); + const body = await blob.arrayBuffer(); + + await fetch(RECEIVER, { + method: 'POST', + headers: { + 'content-type': 'application/octet-stream', + 'x-source-url': url, + }, + body, + }); + + copied += 1; + } catch (err) { + // Récepteur non lancé : on n'interrompt surtout pas la lecture pour autant. + failed += 1; + console.warn('stream-capture : envoi impossible', err); + } + updateBadge(); +} + +browser.webRequest.onBeforeRequest.addListener( + (details) => { + if (!MEDIA_PATTERN.test(details.url)) return {}; + + const filter = browser.webRequest.filterResponseData(details.requestId); + 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(); + void forward(details.url, chunks); + }; + + filter.onerror = () => { + // Le filtre a lâché : la requête suit son cours normalement. + console.warn('stream-capture : filtre interrompu', filter.error); + }; + + return {}; + }, + { urls: ['*://*.doppiocdn.com/*'] }, + ['blocking'], +); + +console.log('stream-capture : actif, récepteur attendu sur', RECEIVER); diff --git a/tools/stream-capture/manifest.json b/tools/stream-capture/manifest.json new file mode 100644 index 0000000..ee6cb6c --- /dev/null +++ b/tools/stream-capture/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 2, + "name": "Stream Capture (test)", + "version": "0.1.0", + "description": "Copie les segments média que Firefox télécharge déjà, vers un récepteur local. Banc d'essai pour Stream Control.", + + "browser_specific_settings": { + "gecko": { + "id": "stream-capture@stream-control.local", + "strict_min_version": "115.0" + } + }, + + "permissions": [ + "webRequest", + "webRequestBlocking", + "*://*.doppiocdn.com/*", + "http://127.0.0.1/*" + ], + + "background": { + "scripts": ["background.js"] + }, + + "browser_action": { + "default_title": "Stream Capture — segments copiés" + } +} diff --git a/tools/stream-capture/receiver.mjs b/tools/stream-capture/receiver.mjs new file mode 100644 index 0000000..f7b6ea7 --- /dev/null +++ b/tools/stream-capture/receiver.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Récepteur local des segments copiés par l'extension Firefox. + * + * node receiver.mjs [--dir ./captures] [--port 8099] + * + * Écrit chaque segment dans un dossier de session, puis à l'arrêt (Ctrl-C) + * assemble le tout et rend son verdict : les octets reçus sont-ils du média + * lisible, ou faut-il une transformation côté lecteur ? + * + * C'est ce verdict qui décide si la capture directe est exploitable. + */ + +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const run = promisify(execFile); + +const args = process.argv.slice(2); +const argOf = (name, fallback) => { + const index = args.indexOf(name); + return index !== -1 && args[index + 1] ? args[index + 1] : fallback; +}; + +const PORT = Number(argOf('--port', '8099')); +const ROOT = path.resolve(argOf('--dir', './captures')); +const SESSION = path.join(ROOT, new Date().toISOString().replace(/[:.]/g, '-')); + +fs.mkdirSync(SESSION, { recursive: true }); + +let index = 0; +const manifests = []; +const segments = []; +let initFile = null; + +function safeName(url) { + const base = path.basename(new URL(url).pathname) || 'segment'; + return base.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80); +} + +const server = http.createServer((req, res) => { + if (req.method !== 'POST' || !req.url?.startsWith('/ingest')) { + res.writeHead(404).end(); + return; + } + + const sourceUrl = String(req.headers['x-source-url'] ?? ''); + const chunks = []; + + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks); + const name = safeName(sourceUrl); + const file = path.join(SESSION, `${String(++index).padStart(4, '0')}_${name}`); + fs.writeFileSync(file, body); + + if (/\.m3u8$/i.test(name)) { + manifests.push({ file, url: sourceUrl, text: body.toString('utf8') }); + process.stdout.write(` manifeste ${name} (${body.length} o)\n`); + } else if (/init/i.test(name)) { + initFile = file; + process.stdout.write(` init ${name} (${body.length} o)\n`); + } else { + segments.push(file); + process.stdout.write(` segment ${name} (${body.length} o)\r`); + } + + res.writeHead(204).end(); + }); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`Récepteur prêt sur http://127.0.0.1:${PORT}/ingest`); + console.log(`Session : ${SESSION}`); + console.log('\nOuvre le stream dans Firefox, puis Ctrl-C ici pour le verdict.\n'); +}); + +// --- Verdict ---------------------------------------------------------------- + +async function verdict() { + console.log('\n\n─── Analyse ───────────────────────────────────────────────\n'); + console.log(` manifestes : ${manifests.length}`); + console.log(` segments : ${segments.length}${initFile ? ' (+ init)' : ' (aucun init)'}`); + + if (!manifests.length && !segments.length) { + console.log('\n Rien n\'a été capturé. Vérifie que l\'extension est chargée et'); + console.log(' que la lecture a bien démarré dans l\'onglet.\n'); + return; + } + + // 1. Le manifeste reçu est-il le direct, ou le leurre publicitaire ? + const live = manifests.find((m) => !/\/cpa\//.test(m.text) && /#EXTINF/.test(m.text)); + if (manifests.length) { + const decoy = manifests.some((m) => /\/cpa\//.test(m.text)); + console.log(`\n Manifeste réel : ${live ? 'oui' : 'non'}`); + console.log(` Leurre publicitaire détecté : ${decoy ? 'oui' : 'non'}`); + const mouflon = manifests.some((m) => /MOUFLON/.test(m.text)); + console.log(` Tags MOUFLON : ${mouflon ? 'présents' : 'absents'}`); + } + + if (!segments.length) { + console.log('\n Aucun segment média capturé : rien à assembler.\n'); + return; + } + + // 2. Les octets reçus forment-ils un média lisible ? + const output = path.join(SESSION, 'assemble.mp4'); + const parts = initFile ? [initFile, ...segments] : segments; + fs.writeFileSync(output, Buffer.concat(parts.map((file) => fs.readFileSync(file)))); + console.log(`\n Assemblé : ${output} (${(fs.statSync(output).size / 1e6).toFixed(1)} Mo)`); + + try { + const probe = await run( + 'ffprobe', + ['-hide_banner', '-v', 'error', '-show_entries', + 'format=format_name,duration:stream=codec_type,codec_name,width,height', + '-of', 'default=noprint_wrappers=1', output], + { timeout: 30_000 }, + ).catch((err) => { + if (err.code === 'ENOENT') throw err; // ffprobe absent : traité plus bas + return { stdout: '', stderr: err.stderr ?? String(err) }; + }); + + const { stdout, stderr } = probe; + + if (stdout.trim()) { + console.log('\n ✓ ffprobe lit le fichier — les segments sont du média exploitable :\n'); + console.log(stdout.split('\n').map((l) => ` ${l}`).join('\n')); + console.log('\n → La capture directe est viable. On peut l\'intégrer à l\'application.\n'); + } else { + console.log('\n ✗ ffprobe ne reconnaît pas le fichier assemblé.'); + if (stderr) console.log(` ${stderr.trim().split('\n')[0]}`); + console.log('\n → Deux causes possibles : assemblage incorrect (init manquant), ou'); + console.log(' segments transformés côté lecteur. Envoie-moi cette sortie.\n'); + } + } catch (err) { + console.log(`\n ffprobe indisponible (${err.code === 'ENOENT' ? 'non installé' : err.message}).`); + console.log(' Installe-le : sudo apt install ffmpeg — puis relance l\'analyse avec :'); + console.log(` ffprobe "${output}"\n`); + } +} + +let closing = false; +process.on('SIGINT', () => { + if (closing) process.exit(1); + closing = true; + server.close(); + void verdict().then(() => process.exit(0)); +});