Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
22
packages/testing/package.json
Normal file
22
packages/testing/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/testing",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"live-source-simulator": "src/simulator.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"simulate": "tsx src/simulator.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"tsx": "^4.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
32
packages/testing/src/scenarios.ts
Normal file
32
packages/testing/src/scenarios.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export interface ScenarioStep {
|
||||
/** Source that changes state in this step. */
|
||||
source: "A" | "B" | "C";
|
||||
action: "go-live" | "fail" | "recover" | "go-offline";
|
||||
/** Seconds to hold after this step before the next one. */
|
||||
holdSeconds: number;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ScenarioStep[];
|
||||
}
|
||||
|
||||
export const SCENARIOS: Record<string, Scenario> = {
|
||||
"dod-flap": {
|
||||
name: "dod-flap",
|
||||
description: "Deterministic Auto Director exercise: A live → B live → A fails → C live → A recovers",
|
||||
steps: [
|
||||
{ source: "A", action: "go-live", holdSeconds: 20 },
|
||||
{ source: "B", action: "go-live", holdSeconds: 20 },
|
||||
{ source: "A", action: "fail", holdSeconds: 10 },
|
||||
{ source: "C", action: "go-live", holdSeconds: 20 },
|
||||
{ source: "A", action: "recover", holdSeconds: 20 },
|
||||
],
|
||||
},
|
||||
"single-live": {
|
||||
name: "single-live",
|
||||
description: "One source goes live and holds — baseline relay check",
|
||||
steps: [{ source: "A", action: "go-live", holdSeconds: 60 }],
|
||||
},
|
||||
};
|
||||
69
packages/testing/src/simulator.ts
Normal file
69
packages/testing/src/simulator.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Live-source simulator: drives ffmpeg testsrc-based streams through a
|
||||
* scripted live/offline/fail scenario so the Auto Director can be exercised
|
||||
* deterministically without real PeerTube instances.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { SCENARIOS, type Scenario } from "./scenarios.js";
|
||||
|
||||
function ffmpegAvailable(): boolean {
|
||||
const probe = spawnSync("ffmpeg", ["-version"], { encoding: "utf8" });
|
||||
return probe.status === 0;
|
||||
}
|
||||
|
||||
function runStep(step: Scenario["steps"][number], sinkUrl: string): void {
|
||||
const label = `${step.source}-${step.action}`;
|
||||
const duration = step.holdSeconds;
|
||||
const args = [
|
||||
"-re",
|
||||
"-f", "lavfi",
|
||||
"-i", `testsrc2=size=640x360:rate=30:duration=${duration}`,
|
||||
"-f", "lavfi",
|
||||
"-i", `sine=frequency=${step.source === "A" ? 440 : step.source === "B" ? 550 : 660}:duration=${duration}`,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-f", "flv",
|
||||
`${sinkUrl}/${label}`,
|
||||
];
|
||||
process.stdout.write(`[${new Date().toISOString()}] ${label}: pushing ${duration}s to ${sinkUrl}\n`);
|
||||
const result = spawnSync("ffmpeg", args, { stdio: "inherit" });
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(`[${label}] ffmpeg exited ${result.status}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function usage(): never {
|
||||
process.stderr.write(
|
||||
"usage: live-source-simulator --scenario <name> [--sink rtmp://localhost:1935/mock]\n" +
|
||||
"scenarios: " + Object.keys(SCENARIOS).join(", ") + "\n",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
process.stdout.write(
|
||||
"usage: live-source-simulator --scenario <name> [--sink rtmp://localhost:1935/mock]\n" +
|
||||
"scenarios: " + Object.keys(SCENARIOS).join(", ") + "\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const scenarioIdx = args.indexOf("--scenario");
|
||||
const sinkIdx = args.indexOf("--sink");
|
||||
if (scenarioIdx === -1 || !args[scenarioIdx + 1]) usage();
|
||||
const scenario: Scenario | undefined = SCENARIOS[args[scenarioIdx + 1] ?? ""];
|
||||
if (!scenario) usage();
|
||||
const sink = (sinkIdx !== -1 && args[sinkIdx + 1]) || "rtmp://localhost:1935/mock";
|
||||
|
||||
if (!ffmpegAvailable()) {
|
||||
process.stderr.write("ffmpeg not found on PATH — install ffmpeg (the simulator is the deterministic test source)\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`scenario ${scenario.name}: ${scenario.description}\n`);
|
||||
for (const step of scenario.steps) {
|
||||
runStep(step, sink);
|
||||
}
|
||||
process.stdout.write("scenario complete\n");
|
||||
10
packages/testing/tsconfig.json
Normal file
10
packages/testing/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue