test : stream cap
This commit is contained in:
92
tools/stream-capture/README.md
Normal file
92
tools/stream-capture/README.md
Normal file
@@ -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.
|
||||
79
tools/stream-capture/background.js
Normal file
79
tools/stream-capture/background.js
Normal file
@@ -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);
|
||||
28
tools/stream-capture/manifest.json
Normal file
28
tools/stream-capture/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
152
tools/stream-capture/receiver.mjs
Normal file
152
tools/stream-capture/receiver.mjs
Normal file
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user