Compare commits
38
Commits
9ae230d047
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5505983dbd | ||
|
|
3d57e9c102 | ||
|
|
d3cb5f6756 | ||
|
|
37787cc4f0 | ||
|
|
f849a87f2f | ||
|
|
deb5dedf2c | ||
|
|
3b221823e7 | ||
|
|
9f7ce7dbd5 | ||
|
|
bd292fdf3d | ||
|
|
7a7f433988 | ||
|
|
84c5c36672 | ||
|
|
c5898f7cf0 | ||
|
|
354e378e74 | ||
|
|
392bc35a0d | ||
|
|
196cb1d3af | ||
|
|
00fc852a32 | ||
|
|
d9f5592e6e | ||
|
|
f1aa08cdf6 | ||
|
|
f70a92880e | ||
|
|
88b13225cd | ||
|
|
67ab289caa | ||
|
|
ef9e243609 | ||
|
|
42a503c206 | ||
|
|
652974e23a | ||
|
|
407e003399 | ||
|
|
968a43b0f4 | ||
|
|
91c7a67d2f | ||
|
|
8615383829 | ||
|
|
10d7ecd405 | ||
|
|
ff554fcff2 | ||
|
|
f8b253ba5e | ||
|
|
c8473b0610 | ||
|
|
3deca91ffe | ||
|
|
edec2edf82 | ||
|
|
17013fe1e5 | ||
|
|
3acb03391a | ||
|
|
9109d3c898 | ||
|
|
9139e225f4 |
@@ -11,6 +11,11 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
|
# libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built
|
||||||
|
# against this host's glibc and ships both lib + dev headers, so the
|
||||||
|
# binding links cleanly inside the Nix sandbox (no manual cmake build).
|
||||||
|
libdatachannel = pkgs.libdatachannel;
|
||||||
|
|
||||||
# Source filter: `path:` literals do NOT respect .gitignore by default,
|
# Source filter: `path:` literals do NOT respect .gitignore by default,
|
||||||
# so a dirty local out/ (stale chunks from previous builds) leaks into
|
# so a dirty local out/ (stale chunks from previous builds) leaks into
|
||||||
# the sandbox. Filter out build artifacts explicitly.
|
# the sandbox. Filter out build artifacts explicitly.
|
||||||
@@ -162,6 +167,7 @@ WRAPPER
|
|||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
pkgs.openssl.dev
|
pkgs.openssl.dev
|
||||||
|
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
|
||||||
pkgs.git # libdatachannel FetchContent clones from GitHub
|
pkgs.git # libdatachannel FetchContent clones from GitHub
|
||||||
pkgs.cacert
|
pkgs.cacert
|
||||||
];
|
];
|
||||||
@@ -180,41 +186,34 @@ WRAPPER
|
|||||||
# pnpm rebuild aborts on the first failing package and runs scripts
|
# pnpm rebuild aborts on the first failing package and runs scripts
|
||||||
# from the wrong cwd — build each native dep explicitly with its own
|
# from the wrong cwd — build each native dep explicitly with its own
|
||||||
# install script. Each failure is tolerated (|| true); the packages
|
# install script. Each failure is tolerated (|| true); the packages
|
||||||
# that matter (opus, datachannel, node-av) are verified at runtime.
|
# that matter (opus) are verified at runtime.
|
||||||
for pkg in \
|
for pkg in \
|
||||||
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \
|
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus
|
||||||
node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \
|
|
||||||
node_modules/.pnpm/zeromq@*/node_modules/zeromq
|
|
||||||
do
|
do
|
||||||
if [ -d "$pkg" ]; then
|
if [ -d "$pkg" ]; then
|
||||||
echo "--- native build: $pkg ---"
|
echo "--- native build: $pkg ---"
|
||||||
(cd "$pkg" && npm run install 2>&1 || true)
|
(cd "$pkg" && npm run install 2>&1 || true)
|
||||||
# node-datachannel's `prebuild -r napi` CLI is broken (TypeError:
|
|
||||||
# expected first argument to be an array) — the install fallback
|
|
||||||
# populates devDeps incl. cmake-js; build directly via cmake-js.
|
|
||||||
if [ "$(basename "$pkg")" = "node-datachannel" ]; then
|
|
||||||
echo "--- datachannel cmake-js compile ---"
|
|
||||||
# Nix splits OpenSSL headers/libs across outputs — merge them
|
|
||||||
# (opensslDevEnv) so FindOpenSSL finds both include + libcrypto.
|
|
||||||
(cd "$pkg" && OPENSSL_ROOT_DIR="${opensslDevEnv}" npm run compile 2>&1 || true)
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "=== Cleaning node-datachannel build tree ==="
|
echo "=== Building libdatachannel-min N-API binding ==="
|
||||||
# Runtime only needs build/Release/node_datachannel.node + dist/ —
|
# The GoLive screen-share stack uses a minimal N-API binding
|
||||||
# the cmake FetchContent sources (build/_deps, ~380MB), intermediate
|
# (native/libdatachannel-min) over nixpkgs libdatachannel.
|
||||||
# cmake files, and the nested node_modules of build tooling (nw-gyp,
|
(
|
||||||
# typescript, puppeteer, eslint, ... ~380MB) are build-time only.
|
cd native/libdatachannel-min
|
||||||
for pkg in node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel
|
# binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev
|
||||||
do
|
# include root, LDC_LIB = lib output dir, NAPI_INCLUDE =
|
||||||
if [ -d "$pkg" ]; then
|
# node-addon-api include root).
|
||||||
( cd "$pkg/build" \
|
NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \
|
||||||
&& find . -mindepth 1 -maxdepth 1 ! -name 'Release' -exec rm -rf {} + ) 2>/dev/null || true
|
-type d -path "*node_modules/node-addon-api" | head -1)
|
||||||
rm -rf "$pkg/node_modules" 2>/dev/null || true
|
echo "NAPI_INCLUDE=$NAPI_INCLUDE"
|
||||||
echo "node-datachannel cleaned: $(du -sh "$pkg" | cut -f1)"
|
LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \
|
||||||
fi
|
NAPI_INCLUDE=$NAPI_INCLUDE \
|
||||||
done
|
npx node-gyp rebuild 2>&1 || true
|
||||||
echo "=== Compiling TypeScript ==="
|
ls -la build/Release/datachannel_min.node 2>/dev/null \
|
||||||
|
&& echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \
|
||||||
|
|| echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)"
|
||||||
|
)
|
||||||
|
echo "=== Compiling TypeScript ===="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||||
node -e "
|
node -e "
|
||||||
@@ -248,6 +247,22 @@ WRAPPER
|
|||||||
mkdir -p $out/lib/gmw-discord-gateway
|
mkdir -p $out/lib/gmw-discord-gateway
|
||||||
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
|
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
|
||||||
|
|
||||||
|
# GoLive native binding — loadNative resolves it relative to
|
||||||
|
# dist/goLive/native.js, i.e. <root>/native/libdatachannel-min/
|
||||||
|
# build/Release/datachannel_min.node; libdatachannel .so must sit
|
||||||
|
# next to it and be on LD_LIBRARY_PATH at runtime.
|
||||||
|
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release
|
||||||
|
cp native/libdatachannel-min/build/Release/datachannel_min.node \
|
||||||
|
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true
|
||||||
|
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc
|
||||||
|
cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \
|
||||||
|
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true
|
||||||
|
# If the binding failed to build, screen share is simply disabled —
|
||||||
|
# the gateway itself must still start.
|
||||||
|
if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then
|
||||||
|
echo "WARN: datachannel_min.node missing — GoLive screen share disabled in this build"
|
||||||
|
fi
|
||||||
|
|
||||||
# Also include drizzle migrations if they exist
|
# Also include drizzle migrations if they exist
|
||||||
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
||||||
|
|
||||||
@@ -256,6 +271,7 @@ WRAPPER
|
|||||||
#!${pkgs.runtimeShell}
|
#!${pkgs.runtimeShell}
|
||||||
cd $out/lib/gmw-discord-gateway
|
cd $out/lib/gmw-discord-gateway
|
||||||
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
||||||
|
export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH
|
||||||
exec ${nodejs}/bin/node dist/index.js
|
exec ${nodejs}/bin/node dist/index.js
|
||||||
WRAPPER
|
WRAPPER
|
||||||
chmod +x $out/bin/gmw-discord-gateway
|
chmod +x $out/bin/gmw-discord-gateway
|
||||||
|
|||||||
@@ -77,12 +77,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
@@ -150,12 +149,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe)
|
// Exclude spam threads (NULL-safe)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -316,12 +314,13 @@ export class MessagesRepository {
|
|||||||
like(pgAttachmentsTable.type, "image/%"),
|
like(pgAttachmentsTable.type, "image/%"),
|
||||||
// Exclude spam threads (NULL-safe for non-thread messages)
|
// Exclude spam threads (NULL-safe for non-thread messages)
|
||||||
...(EXCLUDED_THREAD_IDS.length > 0
|
...(EXCLUDED_THREAD_IDS.length > 0
|
||||||
? [
|
? (() => {
|
||||||
or(
|
const excludeThreads = or(
|
||||||
isNull(pgAttachmentsTable.thread_id),
|
isNull(pgAttachmentsTable.thread_id),
|
||||||
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
)!,
|
);
|
||||||
]
|
return excludeThreads ? [excludeThreads] : [];
|
||||||
|
})()
|
||||||
: []),
|
: []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "term_glossary_cache" (
|
||||||
|
"term" text PRIMARY KEY NOT NULL,
|
||||||
|
"definition" text NOT NULL,
|
||||||
|
"source_url" text DEFAULT '' NOT NULL,
|
||||||
|
"resolved_at" bigint NOT NULL,
|
||||||
|
"hit_count" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_term_glossary_cache_resolved_at" ON "term_glossary_cache" USING btree ("resolved_at");
|
||||||
@@ -99,6 +99,13 @@
|
|||||||
"when": 1785551832190,
|
"when": 1785551832190,
|
||||||
"tag": "0013_rename_mascot_chat_to_chatbot",
|
"tag": "0013_rename_mascot_chat_to_chatbot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785621600000,
|
||||||
|
"tag": "0014_add_term_glossary_cache",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,11 @@
|
|||||||
"target_name": "libdatachannel_min",
|
"target_name": "libdatachannel_min",
|
||||||
"sources": ["binding.cpp"],
|
"sources": ["binding.cpp"],
|
||||||
"include_dirs": [
|
"include_dirs": [
|
||||||
"<!@(node -p \"require('node-addon-api').include\")",
|
"<!(node -e \"console.log(process.env.NAPI_INCLUDE || (() => { try { return require('node-addon-api').include; } catch { return '/nonexistent'; } })())\")",
|
||||||
"/home/code/GMW/services/discord-gateway/node_modules/.pnpm/@lng2004+node-datachannel@0.32.0-20260202/node_modules/@lng2004/node-datachannel/build/_deps/libdatachannel-src/include"
|
"<!(node -e \"const s=process.env.LDC_INCLUDE||'/nix/store/39a85gpfjqy3h3k8jwrwh7m9yc3inqw7-source';console.log(s+'/include')\")"
|
||||||
],
|
],
|
||||||
"libraries": [
|
"libraries": [
|
||||||
"/tmp/ldc-build/libdatachannel.so.0.24.0"
|
"<!(node -e \"console.log(process.env.LDC_LIB || '/tmp/ldc-build/libdatachannel.so.0.24.0')\")"
|
||||||
],
|
],
|
||||||
"cflags": ["-std=c++17", "-fexceptions"],
|
"cflags": ["-std=c++17", "-fexceptions"],
|
||||||
"cflags_cc": ["-std=c++17", "-fexceptions"],
|
"cflags_cc": ["-std=c++17", "-fexceptions"],
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Two-peer RTP capture test: A sends REAL H264 frames through the binding's
|
||||||
|
// packetizer chain to B over localhost. tcpdump (run externally on lo) captures
|
||||||
|
// the RTP; a Python script reassembles AnnexB and ffmpeg decodes it.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node test-rtp-capture.js <mode> mode = "a" (sender) | "b" (receiver)
|
||||||
|
// Sender writes the negotiated SDP pieces to /tmp/rtp-a.sdp /tmp/rtp-b.sdp
|
||||||
|
// Receiver listens and keeps alive.
|
||||||
|
"use strict";
|
||||||
|
const { PeerConnection } = require("./build/Release/datachannel_min.node");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const mode = process.argv[2] || "a";
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pc = new PeerConnection({ iceServers: [] });
|
||||||
|
const audio = pc.addTrack("0", "audio");
|
||||||
|
const video = pc.addTrack("1", "video");
|
||||||
|
|
||||||
|
if (mode === "a") {
|
||||||
|
// Sender: generate offer, hand to B via files, get B's answer
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
fs.writeFileSync("/tmp/rtp-offer.sdp", offer);
|
||||||
|
console.log("[a] offer written", offer.length, "bytes");
|
||||||
|
|
||||||
|
// wait for B to write its answer
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
if (fs.existsSync("/tmp/rtp-answer.sdp")) break;
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
const answer = fs.readFileSync("/tmp/rtp-answer.sdp", "utf8");
|
||||||
|
pc.setRemoteDescription(answer, "answer");
|
||||||
|
|
||||||
|
// wait connected
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
if (pc.state() === "connected") break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
console.log("[a] state:", pc.state());
|
||||||
|
if (pc.state() !== "connected") {
|
||||||
|
console.log("[a] FAILED not connected");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup packetizer like the gateway does
|
||||||
|
video.setPacketizer("h264", 5678, 101, 90000, 5, 0, 10);
|
||||||
|
|
||||||
|
// Real H264 AnnexB (baseline) — read from file created by ffmpeg
|
||||||
|
const data = fs.readFileSync("/tmp/rtp-input.h264");
|
||||||
|
console.log("[a] input h264 bytes:", data.length);
|
||||||
|
|
||||||
|
// Split into NAL units by start codes, then group into access units
|
||||||
|
// the same way Demuxer does (param sets + one slice per frame).
|
||||||
|
const start3 = Buffer.from([0, 0, 1]);
|
||||||
|
const start4 = Buffer.from([0, 0, 0, 1]);
|
||||||
|
const nals = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < data.length) {
|
||||||
|
let start = -1;
|
||||||
|
let startLen = 0;
|
||||||
|
for (let j = i; j < data.length - 3; j++) {
|
||||||
|
if (data[j] === 0 && data[j + 1] === 0 && data[j + 2] === 1) {
|
||||||
|
start = j;
|
||||||
|
startLen = 3;
|
||||||
|
if (j > 0 && data[j - 1] === 0) {
|
||||||
|
start = j - 1;
|
||||||
|
startLen = 4;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start === -1) break;
|
||||||
|
if (start > i) {
|
||||||
|
nals.push(data.subarray(i, start));
|
||||||
|
}
|
||||||
|
i = start + startLen;
|
||||||
|
}
|
||||||
|
console.log("[a] NALs:", nals.length);
|
||||||
|
|
||||||
|
// Group: buffer param sets, flush on slice (like Demuxer.flushAccessUnit)
|
||||||
|
let pending = [];
|
||||||
|
let frameCount = 0;
|
||||||
|
const flush = () => {
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
const parts = pending.map((n) => Buffer.concat([start4, n]));
|
||||||
|
const au = Buffer.concat(parts);
|
||||||
|
pending = [];
|
||||||
|
video.sendFrame(au);
|
||||||
|
video.addTimestamp(3000); // 30fps @ 90kHz
|
||||||
|
frameCount++;
|
||||||
|
};
|
||||||
|
for (const n of nals) {
|
||||||
|
const t = n[0] & 0x1f;
|
||||||
|
if (t === 1 || t === 5) {
|
||||||
|
flush(); // previous AU closed by this slice
|
||||||
|
pending.push(n);
|
||||||
|
} else {
|
||||||
|
pending.push(n); // param set / SEI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
console.log("[a] sent frames:", frameCount);
|
||||||
|
await sleep(3000); // let packets flow
|
||||||
|
console.log("[a] done");
|
||||||
|
pc.close();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
// Receiver: read offer, answer, keep alive
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
if (fs.existsSync("/tmp/rtp-offer.sdp")) break;
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
const offer = fs.readFileSync("/tmp/rtp-offer.sdp", "utf8");
|
||||||
|
pc.setRemoteDescription(offer, "offer");
|
||||||
|
const answer = await pc.createAnswer(offer);
|
||||||
|
fs.writeFileSync("/tmp/rtp-answer.sdp", answer);
|
||||||
|
console.log("[b] answer written");
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
if (pc.state() === "connected") break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
console.log("[b] state:", pc.state());
|
||||||
|
await sleep(10000); // hold while sender streams
|
||||||
|
console.log("[b] done");
|
||||||
|
pc.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("FAILED:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error("TIMEOUT");
|
||||||
|
process.exit(1);
|
||||||
|
}, 30000);
|
||||||
@@ -7,11 +7,8 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@discordjs/opus",
|
"@discordjs/opus",
|
||||||
"@lng2004/node-datachannel",
|
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"node-av",
|
"sharp"
|
||||||
"sharp",
|
|
||||||
"zeromq"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,7 +21,6 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dank074/discord-video-stream": "6.0.0",
|
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@snazzah/davey": "^0.1.11",
|
"@snazzah/davey": "^0.1.11",
|
||||||
@@ -32,7 +28,6 @@
|
|||||||
"discord.js-selfbot-v13": "^3.7.1",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"imghash": "^1.1.4",
|
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
|
|||||||
Generated
+36
-896
File diff suppressed because it is too large
Load Diff
@@ -222,7 +222,10 @@ export async function initializeDiscordGateway() {
|
|||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
logger.info("PostgreSQL database initialized");
|
logger.info("PostgreSQL database initialized");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ error: err }, "Failed to initialize database");
|
logger.error(
|
||||||
|
{ err, errorMsg: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Failed to initialize database",
|
||||||
|
);
|
||||||
throw new DatabaseError(
|
throw new DatabaseError(
|
||||||
`Database initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
`Database initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
@@ -267,7 +270,10 @@ export async function initializeDiscordGateway() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
client.on("error", (err) => {
|
||||||
logger.error({ error: err }, "Client error");
|
logger.error(
|
||||||
|
{ err, errorMsg: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Client error",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
@@ -279,12 +285,58 @@ export async function initializeDiscordGateway() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
process.on("uncaughtException", (err) => {
|
process.on("uncaughtException", (err) => {
|
||||||
logger.error({ error: err }, "Uncaught exception");
|
const code =
|
||||||
|
typeof (err as NodeJS.ErrnoException).code === "string"
|
||||||
|
? (err as NodeJS.ErrnoException).code
|
||||||
|
: "";
|
||||||
|
// Transient stream-teardown errors (voice stop/disconnect races, child
|
||||||
|
// process stdin closed while we still write) are NOT fatal — crashing the
|
||||||
|
// gateway on EPIPE takes the whole bot offline mid-music. Log + continue.
|
||||||
|
if (
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ERR_STREAM_DESTROYED" ||
|
||||||
|
code === "ERR_STREAM_WRITE_AFTER_END" ||
|
||||||
|
code === "ECONNRESET"
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ error: err },
|
||||||
|
"Uncaught transient stream error — continuing",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
err,
|
||||||
|
errorMsg: err instanceof Error ? err.message : String(err),
|
||||||
|
stack: err?.stack,
|
||||||
|
},
|
||||||
|
"Uncaught exception",
|
||||||
|
);
|
||||||
gracefulShutdown("uncaughtException");
|
gracefulShutdown("uncaughtException");
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason, promise) => {
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
logger.error({ reason, promise }, "Unhandled rejection");
|
const err =
|
||||||
|
reason instanceof Error ? reason : new Error(String(reason ?? "unknown"));
|
||||||
|
const code = (err as NodeJS.ErrnoException).code ?? "";
|
||||||
|
// Same transient-teardown policy as uncaughtException: a rejection that
|
||||||
|
// fires while a stream is being torn down (EPIPE after ffmpeg stdin
|
||||||
|
// closes, write-after-destroy, socket reset) must NOT take the whole
|
||||||
|
// gateway offline. Log detail + continue. Everything else still shuts
|
||||||
|
// down so real bugs surface.
|
||||||
|
if (
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ERR_STREAM_DESTROYED" ||
|
||||||
|
code === "ERR_STREAM_WRITE_AFTER_END" ||
|
||||||
|
code === "ECONNRESET"
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ error: err },
|
||||||
|
"Unhandled rejection transient stream error — continuing",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error({ error: err, reason: String(reason) }, "Unhandled rejection");
|
||||||
gracefulShutdown("unhandledRejection");
|
gracefulShutdown("unhandledRejection");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ export class BaseMediaConnection extends EventEmitter {
|
|||||||
}): void {
|
}): void {
|
||||||
// we hardcoded STREAMS_SIMULCAST, which will always be array of 1
|
// we hardcoded STREAMS_SIMULCAST, which will always be array of 1
|
||||||
const stream = d.streams[0];
|
const stream = d.streams[0];
|
||||||
|
console.log(
|
||||||
|
`[goLive:${this.constructor.name}] READY ssrc=${d.ssrc} ip=${d.ip} port=${d.port} streams=${JSON.stringify(d.streams)}`,
|
||||||
|
);
|
||||||
this._webRtcParams = {
|
this._webRtcParams = {
|
||||||
address: d.ip,
|
address: d.ip,
|
||||||
port: d.port,
|
port: d.port,
|
||||||
@@ -183,6 +186,10 @@ export class BaseMediaConnection extends EventEmitter {
|
|||||||
dave_protocol_version?: number;
|
dave_protocol_version?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed");
|
if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed");
|
||||||
|
// DEBUG: dump Discord's real answer SDP — which payload types did it select?
|
||||||
|
console.log(
|
||||||
|
`[goLive:${this.constructor.name}] DISCORD_ANSWER_SDP ${JSON.stringify(d.sdp ?? "").slice(0, 900)}`,
|
||||||
|
);
|
||||||
this._daveProtocolVersion = d.dave_protocol_version ?? 0;
|
this._daveProtocolVersion = d.dave_protocol_version ?? 0;
|
||||||
this.initDave();
|
this.initDave();
|
||||||
// Discord's SDP is garbage — generate our own from its pieces
|
// Discord's SDP is garbage — generate our own from its pieces
|
||||||
@@ -251,6 +258,19 @@ a=ice-lite
|
|||||||
`a=rtpmap:${el.payload_type} ${el.name}/90000`,
|
`a=rtpmap:${el.payload_type} ${el.name}/90000`,
|
||||||
`a=rtpmap:${el.rtx_payload_type} rtx/90000`,
|
`a=rtpmap:${el.rtx_payload_type} rtx/90000`,
|
||||||
`a=fmtp:${el.rtx_payload_type} apt=${el.payload_type}`,
|
`a=fmtp:${el.rtx_payload_type} apt=${el.payload_type}`,
|
||||||
|
// CRITICAL: H264 MUST advertise packetization-mode=1. The encoder
|
||||||
|
// emits baseline slices up to 8KB (>RTP MTU), so the packetizer
|
||||||
|
// fragments them into FU-A units (RFC 6184). Discord's receiver only
|
||||||
|
// reassembles FU-A when packetization-mode=1 is negotiated — without
|
||||||
|
// it the slices (type 28) are DROPPED while SPS/PPS (small single
|
||||||
|
// NALs) and Opus audio (no fragmentation) still arrive → black video
|
||||||
|
// with working audio. profile-level-id=42e01f (constrained baseline
|
||||||
|
// 3.1) matches the -profile:v baseline encoder + SPS VUI rewriter.
|
||||||
|
...(el.name === "H264"
|
||||||
|
? [
|
||||||
|
`a=fmtp:${el.payload_type} level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
`a=rtcp-fb:${el.payload_type} ccm fir`,
|
`a=rtcp-fb:${el.payload_type} ccm fir`,
|
||||||
`a=rtcp-fb:${el.payload_type} nack`,
|
`a=rtcp-fb:${el.payload_type} nack`,
|
||||||
`a=rtcp-fb:${el.payload_type} nack pli`,
|
`a=rtcp-fb:${el.payload_type} nack pli`,
|
||||||
@@ -258,9 +278,10 @@ a=ice-lite
|
|||||||
`a=rtcp-fb:${el.payload_type} transport-cc`,
|
`a=rtcp-fb:${el.payload_type} transport-cc`,
|
||||||
])
|
])
|
||||||
.join("\n");
|
.join("\n");
|
||||||
this._webRtcWrapper.webRtcConn?.setRemoteDescription(
|
const builtAnswer = [audioSection, videoSection, videoRtpMap].join("\n");
|
||||||
[audioSection, videoSection, videoRtpMap].join("\n"),
|
this._webRtcWrapper.webRtcConn?.setRemoteDescription(builtAnswer, "answer");
|
||||||
"answer",
|
console.log(
|
||||||
|
`[goLive:${this.constructor.name}] SELECT_PROTOCOL_ACK processed — remote answer set (${builtAnswer.length}B) video_mline=${videoPayloadTypes.join(" ")}`,
|
||||||
);
|
);
|
||||||
this.emit("select_protocol_ack");
|
this.emit("select_protocol_ack");
|
||||||
}
|
}
|
||||||
@@ -323,14 +344,22 @@ a=ice-lite
|
|||||||
}
|
}
|
||||||
const { op, d, seq } = JSON.parse(e.data as string) as {
|
const { op, d, seq } = JSON.parse(e.data as string) as {
|
||||||
op: number;
|
op: number;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Discord voice WS payload is dynamically typed
|
// biome-ignore lint/suspicious/noExplicitAny: Discord voice WS payload is dynamically typed
|
||||||
d: any;
|
d: any;
|
||||||
seq?: number;
|
seq?: number;
|
||||||
};
|
};
|
||||||
if (seq) this._sequenceNumber = seq;
|
if (seq) this._sequenceNumber = seq;
|
||||||
if (op === VoiceOpCodes.READY) {
|
if (op === VoiceOpCodes.READY) {
|
||||||
this.handleReady(d);
|
this.handleReady(d);
|
||||||
this.setProtocols().then(() => this.ready?.(this._webRtcWrapper));
|
this.setProtocols()
|
||||||
|
.then(() => this.ready?.(this._webRtcWrapper))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// PC can be closed while setProtocols is in flight (stream
|
||||||
|
// teardown) — don't let that become an unhandledRejection.
|
||||||
|
console.log(
|
||||||
|
`[goLive:${this.constructor.name}] setProtocols rejected during teardown: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
this.setVideoAttributes(false);
|
this.setVideoAttributes(false);
|
||||||
} else if (op >= 4000) {
|
} else if (op >= 4000) {
|
||||||
console.error(`${this.constructor.name} connection error`, d);
|
console.error(`${this.constructor.name} connection error`, d);
|
||||||
@@ -519,10 +548,14 @@ a=ice-lite
|
|||||||
const reconnect = () => {
|
const reconnect = () => {
|
||||||
const webRtcConn = this._webRtcWrapper.initWebRtc();
|
const webRtcConn = this._webRtcWrapper.initWebRtc();
|
||||||
webRtcConn.onStateChange((state) => {
|
webRtcConn.onStateChange((state) => {
|
||||||
|
console.log(`[goLive:${this.constructor.name}] pc state => ${state}`);
|
||||||
if (state === "closed" && !this._closed) reconnect();
|
if (state === "closed" && !this._closed) reconnect();
|
||||||
});
|
});
|
||||||
this._webRtcWrapper.onLocalDescription = (sdp) => {
|
this._webRtcWrapper.onLocalDescription = (sdp) => {
|
||||||
const rtc_connection_id = randomUUID();
|
const rtc_connection_id = randomUUID();
|
||||||
|
console.log(
|
||||||
|
`[goLive:${this.constructor.name}] sending SELECT_PROTOCOL (offer ${sdp.length}B, rtc_connection_id=${rtc_connection_id.slice(0, 8)})`,
|
||||||
|
);
|
||||||
this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, {
|
this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, {
|
||||||
protocol: "webrtc",
|
protocol: "webrtc",
|
||||||
codecs: Object.values(CodecPayloadType),
|
codecs: Object.values(CodecPayloadType),
|
||||||
@@ -532,9 +565,18 @@ a=ice-lite
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
// createOffer (binding resolves full SDP incl. candidates after gathering)
|
// createOffer (binding resolves full SDP incl. candidates after gathering)
|
||||||
void webRtcConn.createOffer().then((sdp) => {
|
void webRtcConn
|
||||||
this._webRtcWrapper.onLocalDescription?.(sdp);
|
.createOffer()
|
||||||
});
|
.then((sdp) => {
|
||||||
|
this._webRtcWrapper.onLocalDescription?.(sdp);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// PC closed while offer is gathering (stream teardown / reconnect) —
|
||||||
|
// swallow, the reconnect loop will start a fresh offer.
|
||||||
|
console.log(
|
||||||
|
`[goLive:${this.constructor.name}] createOffer rejected: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
reconnect();
|
reconnect();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -545,49 +587,87 @@ a=ice-lite
|
|||||||
setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void {
|
setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void {
|
||||||
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
|
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
|
||||||
const { audioSsrc, videoSsrc, rtxSsrc } = this._webRtcParams;
|
const { audioSsrc, videoSsrc, rtxSsrc } = this._webRtcParams;
|
||||||
if (!enabled) {
|
const payload = !enabled
|
||||||
this.sendOpcode(VoiceOpCodes.VIDEO, {
|
? {
|
||||||
audio_ssrc: audioSsrc,
|
audio_ssrc: audioSsrc,
|
||||||
video_ssrc: 0,
|
video_ssrc: 0,
|
||||||
rtx_ssrc: 0,
|
rtx_ssrc: 0,
|
||||||
streams: [],
|
streams: [],
|
||||||
});
|
}
|
||||||
} else {
|
: (() => {
|
||||||
if (!attr) throw new Error("Need to specify video attributes");
|
if (!attr) throw new Error("Need to specify video attributes");
|
||||||
this.sendOpcode(VoiceOpCodes.VIDEO, {
|
return {
|
||||||
audio_ssrc: audioSsrc,
|
audio_ssrc: audioSsrc,
|
||||||
video_ssrc: videoSsrc,
|
video_ssrc: videoSsrc,
|
||||||
rtx_ssrc: rtxSsrc,
|
|
||||||
streams: [
|
|
||||||
{
|
|
||||||
type: "video",
|
|
||||||
rid: "100",
|
|
||||||
ssrc: videoSsrc,
|
|
||||||
active: true,
|
|
||||||
quality: 100,
|
|
||||||
rtx_ssrc: rtxSsrc,
|
rtx_ssrc: rtxSsrc,
|
||||||
// hardcode the max bitrate because we don't really know anyway
|
streams: [
|
||||||
max_bitrate: 10000 * 1000,
|
{
|
||||||
max_framerate: enabled ? attr.fps : 0,
|
type: "video",
|
||||||
max_resolution: {
|
rid: "100",
|
||||||
type: "fixed",
|
ssrc: videoSsrc,
|
||||||
width: attr.width,
|
active: true,
|
||||||
height: attr.height,
|
quality: 100,
|
||||||
},
|
rtx_ssrc: rtxSsrc,
|
||||||
},
|
// hardcode the max bitrate because we don't really know anyway
|
||||||
],
|
max_bitrate: 10000 * 1000,
|
||||||
});
|
max_framerate: enabled ? attr.fps : 0,
|
||||||
}
|
max_resolution: {
|
||||||
|
type: "fixed",
|
||||||
|
width: attr.width,
|
||||||
|
height: attr.height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
// CRITICAL: The VIDEO opcode (op 12) is what tells Discord's media server
|
||||||
|
// to actually forward the video RTP stream on video_ssrc. sendOpcode() is a
|
||||||
|
// no-op when ws.readyState !== OPEN — and in GoLive the StreamConnection's
|
||||||
|
// WebSocket can still be in CONNECTING immediately after SELECT_PROTOCOL_ACK
|
||||||
|
// (the ack listener resolves playStream, but the data channel / ws open
|
||||||
|
// handshake may lag by a few ms). A dropped op 12 → Discord never activates
|
||||||
|
// the video SSRC → black/broken video while audio (whose SPEAKING on the
|
||||||
|
// VoiceConnection already fired) plays fine. Retry until the ws is OPEN
|
||||||
|
// instead of silently dropping this mandatory signal.
|
||||||
|
this.sendOpcodeWhenOpen(VoiceOpCodes.VIDEO, payload, "VIDEO");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set speaking status */
|
/** Set speaking status */
|
||||||
setSpeaking(speaking: boolean): void {
|
setSpeaking(speaking: boolean): void {
|
||||||
if (!this._webRtcParams) throw new Error("WebRTC connection not ready");
|
if (!this._webRtcParams) throw new Error("WebRTC connection not ready");
|
||||||
this.sendOpcode(VoiceOpCodes.SPEAKING, {
|
const payload = {
|
||||||
delay: 0,
|
delay: 0,
|
||||||
speaking: speaking ? 1 : 0,
|
speaking: speaking ? 1 : 0,
|
||||||
ssrc: this._webRtcParams.audioSsrc,
|
ssrc: this._webRtcParams.audioSsrc,
|
||||||
});
|
};
|
||||||
|
// Same race as setVideoAttributes: SPEAKING (op 5) must reach Discord. Retry
|
||||||
|
// until the ws is OPEN rather than dropping it on a transient not-yet-open.
|
||||||
|
this.sendOpcodeWhenOpen(VoiceOpCodes.SPEAKING, payload, "SPEAKING");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an opcode, retrying for a short window if the WebSocket is not yet
|
||||||
|
* OPEN. Discord's media signaling (VIDEO/op12, SPEAKING/op5) is mandatory —
|
||||||
|
* a silent no-op (the default sendOpcode behaviour when ws is still
|
||||||
|
* CONNECTING) breaks GoLive video while leaving audio intact. We wait for
|
||||||
|
* the open state instead of dropping.
|
||||||
|
*/
|
||||||
|
private sendOpcodeWhenOpen(code: number, data: unknown, label: string): void {
|
||||||
|
const attempt = (triesLeft: number) => {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.sendOpcode(code, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (triesLeft <= 0) {
|
||||||
|
console.error(
|
||||||
|
`[goLive:${this.constructor.name}] ${label} opcode (op=${code}) DROPPED — ws never opened (state=${this.ws?.readyState ?? "null"})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ws still CONNECTING (or briefly closed during reconnect) — retry.
|
||||||
|
setTimeout(() => attempt(triesLeft - 1), 50);
|
||||||
|
};
|
||||||
|
attempt(40); // up to ~2s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,18 +13,47 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
|
import type { GoLiveFrame } from "./BaseMediaStream.js";
|
||||||
|
|
||||||
export enum AVCodecID {
|
/** 4-byte AnnexB start code (00 00 00 01) used when building access units. */
|
||||||
AV_CODEC_ID_H264 = 27,
|
const startCode4 = Buffer.from([0, 0, 0, 1]);
|
||||||
AV_CODEC_ID_HEVC = 173,
|
|
||||||
AV_CODEC_ID_VP8 = 139,
|
/**
|
||||||
AV_CODEC_ID_VP9 = 167,
|
* Resolve ffmpeg/ffprobe binary. Prefers explicit env override, then PATH,
|
||||||
AV_CODEC_ID_AV1 = 225,
|
* then a Nix-store ffmpeg-headless (the GMW flake provides it in the service
|
||||||
AV_CODEC_ID_OPUS = 86019,
|
* profile, but dev shells / tests may not have it on PATH).
|
||||||
|
*/
|
||||||
|
function resolveBin(name: "ffmpeg"): string {
|
||||||
|
const override = process.env.FFMPEG_PATH;
|
||||||
|
if (override && existsSync(override)) return override;
|
||||||
|
// Nix store scan: <store>/<hash>-ffmpeg-headless-*/bin/<name>
|
||||||
|
const store = "/nix/store";
|
||||||
|
if (existsSync(store)) {
|
||||||
|
const entries = readdirSync(store);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.includes("ffmpeg-headless-")) continue;
|
||||||
|
const candidate = join(store, entry, "bin", name);
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name; // fall back to PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FFMPEG = resolveBin("ffmpeg");
|
||||||
|
|
||||||
|
export const AVCodecID = {
|
||||||
|
AV_CODEC_ID_H264: 27,
|
||||||
|
AV_CODEC_ID_HEVC: 173,
|
||||||
|
AV_CODEC_ID_VP8: 139,
|
||||||
|
AV_CODEC_ID_VP9: 167,
|
||||||
|
AV_CODEC_ID_AV1: 225,
|
||||||
|
AV_CODEC_ID_OPUS: 86019,
|
||||||
|
} as const;
|
||||||
|
export type AVCodecID = (typeof AVCodecID)[keyof typeof AVCodecID];
|
||||||
|
|
||||||
export const AV_PKT_FLAG_KEY = 1;
|
export const AV_PKT_FLAG_KEY = 1;
|
||||||
|
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
@@ -48,149 +77,336 @@ export interface DemuxedStream {
|
|||||||
stream: PassThrough;
|
stream: PassThrough;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run ffprobe JSON on a file URL, return raw stream descriptors. */
|
/**
|
||||||
|
* Probe a media file for stream info using ffmpeg's stderr (the
|
||||||
|
* ffmpeg-headless Nix package ships ffmpeg but not ffprobe). Returns
|
||||||
|
* stream descriptors in the same shape ffprobe -show_streams would.
|
||||||
|
*/
|
||||||
export async function probeStreams(
|
export async function probeStreams(
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<Array<Record<string, unknown>>> {
|
): Promise<Array<Record<string, unknown>>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn("ffprobe", [
|
const proc = spawn(FFMPEG, [
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
"error",
|
"info",
|
||||||
"-i",
|
"-i",
|
||||||
url,
|
url,
|
||||||
"-print_format",
|
"-f",
|
||||||
"json",
|
"null",
|
||||||
"-show_streams",
|
"-",
|
||||||
]);
|
]);
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
proc.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
|
|
||||||
proc.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
proc.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
||||||
proc.on("close", (code) => {
|
proc.on("close", () => {
|
||||||
if (code === 0) {
|
// Parse "Stream #0:0: Video: h264 (High), yuv420p, 640x360, 30 fps"
|
||||||
try {
|
const streams: Array<Record<string, unknown>> = [];
|
||||||
const parsed = JSON.parse(stdout);
|
const re = /Stream #0:(\d+): (Video|Audio): ([^,]+)/g;
|
||||||
resolve(parsed.streams ?? []);
|
let m: RegExpExecArray | null;
|
||||||
} catch (e) {
|
// biome-ignore lint/suspicious/noAssignInExpressions: regex loop idiom
|
||||||
reject(new Error(`Failed to parse ffprobe output: ${e}`));
|
while ((m = re.exec(stderr)) !== null) {
|
||||||
|
const [full, idx, kind, codecRaw] = m;
|
||||||
|
void full;
|
||||||
|
const codecName = codecRaw.split(" ")[0].toLowerCase();
|
||||||
|
const stream: Record<string, unknown> = {
|
||||||
|
index: Number(idx),
|
||||||
|
codec_type: kind.toLowerCase(),
|
||||||
|
codec_name: codecName,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
r_frame_rate: "0/1",
|
||||||
|
sample_rate: 0,
|
||||||
|
};
|
||||||
|
// dimensions: "640x360"
|
||||||
|
const dim = /(\d{2,5})x(\d{2,5})/.exec(stderr.slice(m.index));
|
||||||
|
if (dim) {
|
||||||
|
stream.width = Number(dim[1]);
|
||||||
|
stream.height = Number(dim[2]);
|
||||||
}
|
}
|
||||||
} else {
|
// fps: "30 fps" or "29.97 fps"
|
||||||
reject(new Error(`ffprobe failed (${code}): ${stderr}`));
|
const fps = /(\d+(?:\.\d+)?) fps/.exec(stderr.slice(m.index));
|
||||||
|
if (fps) {
|
||||||
|
const v = Number(fps[1]);
|
||||||
|
stream.r_frame_rate = `${Math.round(v * 1000)}/1000`;
|
||||||
|
}
|
||||||
|
// sample rate for audio: "48000 Hz"
|
||||||
|
const sr = /(\d+) Hz/.exec(stderr.slice(m.index));
|
||||||
|
if (sr) stream.sample_rate = Number(sr[1]);
|
||||||
|
streams.push(stream);
|
||||||
}
|
}
|
||||||
|
resolve(streams);
|
||||||
});
|
});
|
||||||
|
proc.on("error", (err) => reject(err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Demux input (URL string or readable stream) into video frames on a
|
* Demux input (URL string or readable stream) into video frames on a
|
||||||
* PassThrough. Uses ffmpeg -f h264 -c copy for video-only AnnexB output.
|
* PassThrough. Streams input DIRECTLY into ffmpeg (no spool-to-file — the
|
||||||
* Returns stream info + the video pipe. Audio is not extracted (GoLive
|
* live NUT/H264 source never ends, so spooling deadlocks). ffmpeg emits
|
||||||
* screen share sends silence / uses Discord's mixed audio).
|
* AnnexB H264 on stdout; NAL units are split into frames on the fly.
|
||||||
|
* Video metadata is parsed from ffmpeg stderr during init.
|
||||||
*/
|
*/
|
||||||
export async function demux(
|
export async function demux(
|
||||||
input: string | PassThrough,
|
input: string | PassThrough,
|
||||||
_opts: { format: string },
|
opts: { format: string; frameRate?: number },
|
||||||
): Promise<{
|
): Promise<{
|
||||||
video: DemuxedStream | undefined;
|
video: DemuxedStream | undefined;
|
||||||
audio: DemuxedStream | undefined;
|
audio: DemuxedStream | undefined;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
}> {
|
}> {
|
||||||
const _label = randomUUID();
|
|
||||||
const vPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
|
const vPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
|
||||||
const aPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
|
const aPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
|
||||||
|
|
||||||
// Probe for codec + dimensions
|
const isStream = typeof input !== "string";
|
||||||
let streams: Array<Record<string, unknown>> = [];
|
// NUT/matroska input (prepareStream with includeAudio) carries audio; the
|
||||||
if (typeof input === "string") {
|
// h264 path is video-only raw AnnexB. Video always goes to stdout (pipe:1);
|
||||||
streams = await probeStreams(input);
|
// audio goes to fd3 (pipe:3) so stderr stays free for metadata parsing.
|
||||||
|
const containerFormat =
|
||||||
|
isStream && opts.format !== "h264" ? opts.format : null;
|
||||||
|
const withAudio = isStream && containerFormat !== null;
|
||||||
|
const args: string[] = [
|
||||||
|
"-hide_banner",
|
||||||
|
// info level: stream init lines ("Stream #0:0: Video: h264...") go to
|
||||||
|
// stderr and are parsed for dimensions/fps.
|
||||||
|
"-loglevel",
|
||||||
|
"info",
|
||||||
|
// Input format hint: raw H264 has NO magic header, so ffmpeg's
|
||||||
|
// auto-detection fails with "Invalid data found when processing input"
|
||||||
|
// whenever the first bytes arrive late/buffered. Pin the demuxer input
|
||||||
|
// format for streams (NUT for the audio-capable path).
|
||||||
|
...(withAudio
|
||||||
|
? ["-f", containerFormat as string]
|
||||||
|
: isStream
|
||||||
|
? ["-f", "h264"]
|
||||||
|
: []),
|
||||||
|
"-i",
|
||||||
|
isStream ? "pipe:0" : input,
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-f",
|
||||||
|
"h264",
|
||||||
|
"pipe:1",
|
||||||
|
...(withAudio
|
||||||
|
? ["-map", "0:a:0?", "-c:a", "copy", "-f", "opus", "pipe:3"]
|
||||||
|
: ["-an"]),
|
||||||
|
];
|
||||||
|
const proc = spawn(FFMPEG, args, {
|
||||||
|
stdio: isStream
|
||||||
|
? withAudio
|
||||||
|
? ["pipe", "pipe", "pipe", "pipe"]
|
||||||
|
: ["pipe", "pipe", "pipe"]
|
||||||
|
: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[goLive:Demuxer] spawn ffmpeg pid=${proc.pid} input=${isStream ? "stream" : input} args=${args.join(" ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pipe live input straight into ffmpeg stdin — never await stream end.
|
||||||
|
if (isStream && proc.stdin) {
|
||||||
|
input.pipe(proc.stdin);
|
||||||
|
input.on("error", () => proc.stdin?.destroy());
|
||||||
}
|
}
|
||||||
|
|
||||||
const v = streams.find((s) => s.codec_type === "video");
|
// Audio: ffmpeg writes Ogg Opus on fd3 (pipe:3). Parse OGG pages into
|
||||||
const a = streams.find((s) => s.codec_type === "audio");
|
// opus packets and emit them as GoLiveFrames (20ms, 48kHz) on aPipe.
|
||||||
|
if (withAudio && proc.stdio[3]) {
|
||||||
|
createOggOpusDemux(
|
||||||
|
proc.stdio[3] as unknown as NodeJS.ReadableStream,
|
||||||
|
aPipe,
|
||||||
|
);
|
||||||
|
console.log("[goLive:Demuxer] audio pipe wired (fd3 → Ogg Opus → aPipe)");
|
||||||
|
}
|
||||||
|
|
||||||
let vInfo: DemuxedStream | undefined;
|
// Set true once stderr metadata has been parsed (see handler below).
|
||||||
|
let parsedMeta = false;
|
||||||
|
// Track which stream kinds we've seen. We must NOT stop parsing on the
|
||||||
|
// first stream found: ffmpeg can print the video line and audio line in
|
||||||
|
// separate stderr chunks (input arrives slowly), and the old
|
||||||
|
// early-return (`if (parsedMeta) return`) dropped the audio line forever
|
||||||
|
// → aInfo undefined → no audio RTP → static GoLive tile.
|
||||||
|
let seenVideo = false;
|
||||||
|
let seenAudio = false;
|
||||||
|
|
||||||
|
// Parse stream metadata from ffmpeg stderr as it arrives (first chunk has
|
||||||
|
// the init lines). Fall back to H264 defaults if parsing fails.
|
||||||
|
let vInfo: DemuxedStream = {
|
||||||
|
codec: AVCodecID.AV_CODEC_ID_H264,
|
||||||
|
codecName: "h264",
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
framerate_num: 0,
|
||||||
|
framerate_den: 1,
|
||||||
|
sample_rate: 0,
|
||||||
|
stream: vPipe,
|
||||||
|
};
|
||||||
let aInfo: DemuxedStream | undefined;
|
let aInfo: DemuxedStream | undefined;
|
||||||
|
// With audio expected (NUT input), ALWAYS expose an audio stream even if
|
||||||
if (v) {
|
// ffmpeg's audio init line hasn't arrived in stderr yet. prepareStream
|
||||||
const codecName = (v.codec_name as string) ?? "h264";
|
// encodes libopus into the NUT unconditionally (`-map 0:a:0? -c:a libopus`),
|
||||||
const rFrame = (v.r_frame_rate as string) ?? "0/1";
|
// so fd3 WILL carry Ogg Opus — aInfo must not stay undefined just because
|
||||||
const [num, den] = rFrame.split("/").map((n) => Number(n));
|
// the metadata line raced the resolve. The stderr handler below upgrades
|
||||||
vInfo = {
|
// this default with real sample_rate metadata when the line lands.
|
||||||
codec:
|
if (withAudio) {
|
||||||
AVCodecID[
|
|
||||||
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
|
|
||||||
"AV_CODEC_ID_H264"
|
|
||||||
],
|
|
||||||
codecName,
|
|
||||||
width: (v.width as number) ?? 0,
|
|
||||||
height: (v.height as number) ?? 0,
|
|
||||||
framerate_num: num ?? 0,
|
|
||||||
framerate_den: den ?? 1,
|
|
||||||
sample_rate: 0,
|
|
||||||
stream: vPipe,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a) {
|
|
||||||
const codecName = (a.codec_name as string) ?? "opus";
|
|
||||||
aInfo = {
|
aInfo = {
|
||||||
codec:
|
codec: AVCodecID.AV_CODEC_ID_OPUS,
|
||||||
AVCodecID[
|
codecName: "opus",
|
||||||
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
|
|
||||||
"AV_CODEC_ID_OPUS"
|
|
||||||
],
|
|
||||||
codecName,
|
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
framerate_num: 0,
|
framerate_num: 0,
|
||||||
framerate_den: 0,
|
framerate_den: 0,
|
||||||
sample_rate: Number(a.sample_rate) ?? 0,
|
sample_rate: 48000,
|
||||||
stream: aPipe,
|
stream: aPipe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
let stderrBuf = "";
|
||||||
// Spawn ffmpeg — extract raw video (AnnexB for H264) to stdout
|
if (proc.stderr) {
|
||||||
const isUrl = typeof input === "string";
|
proc.stderr.on("data", (d: Buffer) => {
|
||||||
const args: string[] = [
|
const text = d.toString();
|
||||||
"-hide_banner",
|
stderrBuf = (stderrBuf + text).slice(-16384);
|
||||||
"-loglevel",
|
// Surface actionable lines: ffmpeg errors + stream init lines
|
||||||
"error",
|
if (/error|invalid|no such|failed|cannot|not found|unable/i.test(text)) {
|
||||||
...(isUrl ? ["-i", input] : ["-i", "pipe:0"]),
|
console.log(
|
||||||
"-c:v",
|
`[goLive:Demuxer] ffmpeg stderr: ${text.trim().split("\n").slice(0, 4).join(" | ")}`,
|
||||||
"copy",
|
);
|
||||||
"-an", // no audio in this minimal demuxer
|
}
|
||||||
"-f",
|
const streamRe = /Stream #0:(\d+): (Video|Audio): ([^,]+)/g;
|
||||||
"h264",
|
let m: RegExpExecArray | null;
|
||||||
"pipe:1",
|
const found: Array<{ kind: string; codecRaw: string }> = [];
|
||||||
];
|
// biome-ignore lint/suspicious/noAssignInExpressions: regex loop idiom
|
||||||
|
while ((m = streamRe.exec(stderrBuf)) !== null) {
|
||||||
const proc = isUrl
|
found.push({ kind: m[2], codecRaw: m[3] });
|
||||||
? spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] })
|
}
|
||||||
: spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"] });
|
if (process.env.GMW_DEMUX_DEBUG) {
|
||||||
|
console.log(
|
||||||
if (proc.stdin && !isUrl) {
|
`[goLive:Demuxer] DEBUG stderrBuf=${JSON.stringify(stderrBuf.slice(0, 300))} found=${JSON.stringify(found)}`,
|
||||||
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
|
);
|
||||||
input.on("end", () => proc.stdin?.end());
|
}
|
||||||
input.on("error", () => proc.stdin?.destroy());
|
const v = found.find((s) => s.kind === "Video");
|
||||||
|
const a = found.find((s) => s.kind === "Audio");
|
||||||
|
if (v) {
|
||||||
|
seenVideo = true;
|
||||||
|
const codecName = v.codecRaw.split(" ")[0].toLowerCase();
|
||||||
|
const dim = /(\d{2,5})x(\d{2,5})/.exec(stderrBuf);
|
||||||
|
const fps = /(\d+(?:\.\d+)?) fps/.exec(stderrBuf);
|
||||||
|
vInfo = {
|
||||||
|
codec:
|
||||||
|
AVCodecID[
|
||||||
|
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
|
||||||
|
"AV_CODEC_ID_H264"
|
||||||
|
] ?? AVCodecID.AV_CODEC_ID_H264,
|
||||||
|
codecName,
|
||||||
|
width: dim ? Number(dim[1]) : 0,
|
||||||
|
height: dim ? Number(dim[2]) : 0,
|
||||||
|
framerate_num: fps ? Math.round(Number(fps[1]) * 1000) : 0,
|
||||||
|
framerate_den: fps ? 1000 : 1,
|
||||||
|
sample_rate: 0,
|
||||||
|
stream: vPipe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (a) {
|
||||||
|
seenAudio = true;
|
||||||
|
const codecName = a.codecRaw.split(" ")[0].toLowerCase();
|
||||||
|
const sr = /(\d+) Hz/.exec(stderrBuf);
|
||||||
|
aInfo = {
|
||||||
|
codec:
|
||||||
|
AVCodecID[
|
||||||
|
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
|
||||||
|
"AV_CODEC_ID_OPUS"
|
||||||
|
] ?? AVCodecID.AV_CODEC_ID_OPUS,
|
||||||
|
codecName,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
framerate_num: 0,
|
||||||
|
framerate_den: 0,
|
||||||
|
sample_rate: sr ? Number(sr[1]) : 0,
|
||||||
|
stream: aPipe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Mark that at least one stream kind was seen. Note: we must NOT
|
||||||
|
// resolve the metadata wait on the FIRST stream kind alone. With live
|
||||||
|
// NUT input, ffmpeg can print the video init line in one stderr chunk
|
||||||
|
// and the audio init line in the NEXT chunk (NUT info-stream packets
|
||||||
|
// arrive as ffmpeg reads them from the pipe). The old code returned
|
||||||
|
// immediately on `parsedMeta=true` — the audio line then landed in the
|
||||||
|
// handler AFTER `return { audio: aInfo }` had already captured
|
||||||
|
// `undefined` → no audio RTP → static GoLive tile even though the NUT
|
||||||
|
// carried audio. Wait for BOTH kinds (when audio is expected).
|
||||||
|
if (seenVideo || seenAudio) parsedMeta = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan stdout for NAL units. Each NAL unit (between start codes) is one frame
|
// Wait (briefly) for ffmpeg to print its stream init lines on stderr so
|
||||||
// payload. We emit them individually; the packetizer chain handles FU-A.
|
// vInfo/aInfo carry real metadata. With audio expected, wait for BOTH the
|
||||||
|
// video and audio init lines (they may arrive in separate stderr chunks on
|
||||||
|
// live input); the timeout covers slow starts / genuinely audio-less input.
|
||||||
|
const allSeen = () =>
|
||||||
|
withAudio ? seenVideo && seenAudio : seenVideo || seenAudio;
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (allSeen()) {
|
||||||
|
clearInterval(check);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 25);
|
||||||
|
}),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 3000)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Scan stdout for AnnexB NAL units and group them into ACCESS UNITS
|
||||||
|
// (one picture). Discord's H264 decoder requires a complete access unit —
|
||||||
|
// parameter sets + slice — inside a single RTP frame. Emitting each NAL
|
||||||
|
// as its own frame (SPS/PPS/SEI separate from the slice) makes the decoder
|
||||||
|
// unable to produce ANY picture: production showed a black GoLive tile
|
||||||
|
// despite frames flowing (5892B slices + 4B PPS + 33B SPS as separate
|
||||||
|
// frames, each with a near-zero RTP timestamp delta). We therefore buffer
|
||||||
|
// NALs and flush one frame per slice, prepending the parameter sets that
|
||||||
|
// precede it, and timestamp it as ONE frame at the video frame rate.
|
||||||
let videoBuf = Buffer.alloc(0);
|
let videoBuf = Buffer.alloc(0);
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
let pendingNals: Buffer[] = [];
|
||||||
|
let pendingHasSlice = false;
|
||||||
|
let pendingIsKey = false;
|
||||||
|
// Raw H264 streams carry no timing info — ffmpeg's h264 demuxer guesses
|
||||||
|
// 25fps on stderr. Prefer the caller's explicit frameRate (the encode
|
||||||
|
// setting); it drives both RTP timestamp advance and pacing.
|
||||||
|
const videoFps =
|
||||||
|
opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30);
|
||||||
|
|
||||||
const emitFrame = (nal: Uint8Array, isKeyFrame: boolean) => {
|
const flushAccessUnit = () => {
|
||||||
|
if (pendingNals.length === 0) return;
|
||||||
|
// AnnexB access unit: 00 00 00 01 + NAL for every buffered NAL. The
|
||||||
|
// packetizer (H264RtpPacketizer, StartSequence separator) needs the
|
||||||
|
// start codes to find NAL boundaries inside the frame.
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const n of pendingNals) parts.push(startCode4, n);
|
||||||
|
const au = Buffer.concat(parts);
|
||||||
|
const isKey = pendingIsKey;
|
||||||
|
pendingNals = [];
|
||||||
|
pendingHasSlice = false;
|
||||||
|
pendingIsKey = false;
|
||||||
vPipe.write({
|
vPipe.write({
|
||||||
data: Buffer.from(nal),
|
data: au,
|
||||||
|
// One frame at videoFps: duration=1 in a 1/fps timebase →
|
||||||
|
// BaseMediaStream computes frametime=1000/fps ms → the RTP timestamp
|
||||||
|
// advances clockRate/fps per frame (3000 @ 30fps / 90kHz), which is
|
||||||
|
// what Discord's receiver expects for real-time video.
|
||||||
pts: frameCount,
|
pts: frameCount,
|
||||||
duration: 1,
|
duration: 1,
|
||||||
timeBase: { num: 1, den: 90000 },
|
timeBase: { num: 1, den: videoFps },
|
||||||
flags: isKeyFrame ? AV_PKT_FLAG_KEY : 0,
|
flags: isKey ? AV_PKT_FLAG_KEY : 0,
|
||||||
streamIndex: 0,
|
streamIndex: 0,
|
||||||
free: () => {},
|
free: () => {},
|
||||||
});
|
});
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
if (frameCount === 1 || frameCount % 30 === 0) {
|
||||||
|
console.log(
|
||||||
|
`[goLive:Demuxer] frames=${frameCount} last=${au.length}B key=${isKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (proc.stdout) {
|
if (proc.stdout) {
|
||||||
@@ -237,8 +453,21 @@ export async function demux(
|
|||||||
while (end > 0 && nal[end - 1] === 0) end--;
|
while (end > 0 && nal[end - 1] === 0) end--;
|
||||||
if (end > 0) {
|
if (end > 0) {
|
||||||
const nalTrimmed = nal.subarray(0, end);
|
const nalTrimmed = nal.subarray(0, end);
|
||||||
const isIdr = (nalTrimmed[0] & 0x1f) === 5; // IDR
|
const nalType = nalTrimmed[0] & 0x1f;
|
||||||
emitFrame(nalTrimmed, isIdr);
|
const isSlice = nalType === 1 || nalType === 5;
|
||||||
|
if (isSlice) {
|
||||||
|
// A new slice while one is pending closes the previous
|
||||||
|
// access unit (x264 emits one slice per frame).
|
||||||
|
if (pendingHasSlice) flushAccessUnit();
|
||||||
|
pendingNals.push(Buffer.from(nalTrimmed));
|
||||||
|
pendingHasSlice = true;
|
||||||
|
if (nalType === 5) pendingIsKey = true;
|
||||||
|
} else {
|
||||||
|
// Parameter-set / SEI / AUD / filler NAL. After a slice these
|
||||||
|
// belong to the NEXT access unit — flush the completed frame.
|
||||||
|
if (pendingHasSlice) flushAccessUnit();
|
||||||
|
pendingNals.push(Buffer.from(nalTrimmed));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Skip the 00 00 01 at scPos-3 to find next
|
// Skip the 00 00 01 at scPos-3 to find next
|
||||||
@@ -256,21 +485,12 @@ export async function demux(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
proc.stdout.on("end", () => {
|
proc.stdout.on("end", () => {
|
||||||
if (videoBuf.length > 0) {
|
flushAccessUnit();
|
||||||
let end = videoBuf.length;
|
|
||||||
while (end > 0 && videoBuf[end - 1] === 0) end--;
|
|
||||||
if (end > 0) emitFrame(videoBuf.subarray(0, end), false);
|
|
||||||
}
|
|
||||||
vPipe.end();
|
vPipe.end();
|
||||||
aPipe.end();
|
aPipe.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proc.stderr) {
|
|
||||||
proc.stderr.on("data", () => {
|
|
||||||
/* errors swallowed */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
proc.on("close", () => {
|
proc.on("close", () => {
|
||||||
vPipe.end();
|
vPipe.end();
|
||||||
aPipe.end();
|
aPipe.end();
|
||||||
@@ -284,3 +504,97 @@ export async function demux(
|
|||||||
|
|
||||||
return { video: vInfo, audio: aInfo, close };
|
return { video: vInfo, audio: aInfo, close };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an Ogg Opus byte stream (as written by ffmpeg's `-f opus` muxer)
|
||||||
|
* into individual opus packets and push them onto `out` as GoLiveFrames
|
||||||
|
* (duration 960 @ 48kHz = 20ms, matching the OpusRtpPacketizer clock).
|
||||||
|
*
|
||||||
|
* OGG page structure:
|
||||||
|
* "OggS" | ver(1) | header_type(1) | granule(8 LE) | serial(4) | seq(4) |
|
||||||
|
* crc(4) | page_segments(1) | segment_table[n] | payload
|
||||||
|
* Lacing: a value < 255 ends a packet; 255 continues it (0.5KB chunk).
|
||||||
|
* The first packet is OpusHead (19B) — skipped, as is OpusTags.
|
||||||
|
*/
|
||||||
|
function createOggOpusDemux(
|
||||||
|
input: NodeJS.ReadableStream,
|
||||||
|
out: PassThrough,
|
||||||
|
): void {
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
// Packets assembled from lacing; packetParts accumulates across pages
|
||||||
|
// when a packet spans a page boundary (continued flag / 255 lacing).
|
||||||
|
let packetParts: Buffer[] = [];
|
||||||
|
let headerDone = false;
|
||||||
|
let frameIndex = 0;
|
||||||
|
|
||||||
|
const emitPacket = (packet: Buffer) => {
|
||||||
|
if (!headerDone) {
|
||||||
|
// First packet = OpusHead ("OpusHead"), second = OpusTags. Skip both.
|
||||||
|
const magic = packet.toString("latin1", 0, 8);
|
||||||
|
if (magic === "OpusHead" || magic === "OpusTags") return;
|
||||||
|
headerDone = true;
|
||||||
|
}
|
||||||
|
out.write({
|
||||||
|
data: packet,
|
||||||
|
pts: frameIndex * 960,
|
||||||
|
duration: 960,
|
||||||
|
timeBase: { num: 1, den: 48000 },
|
||||||
|
free: () => {},
|
||||||
|
} satisfies GoLiveFrame);
|
||||||
|
frameIndex++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processPages = () => {
|
||||||
|
while (true) {
|
||||||
|
// Sync to "OggS"
|
||||||
|
const sync = buf.indexOf("OggS", 0, "latin1");
|
||||||
|
if (sync === -1) {
|
||||||
|
// Keep the tail (partial sync pattern) for the next chunk
|
||||||
|
buf = buf.length > 3 ? buf.subarray(buf.length - 3) : buf;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sync > 0) buf = buf.subarray(sync);
|
||||||
|
if (buf.length < 27) return; // need full page header
|
||||||
|
const numSeg = buf[26];
|
||||||
|
if (buf.length < 27 + numSeg) return; // need segment table
|
||||||
|
let payloadLen = 0;
|
||||||
|
for (let i = 0; i < numSeg; i++) payloadLen += buf[27 + i];
|
||||||
|
if (buf.length < 27 + numSeg + payloadLen) return; // need payload
|
||||||
|
|
||||||
|
const headerType = buf[5];
|
||||||
|
// Extract packets from the payload using lacing values
|
||||||
|
let off = 27 + numSeg;
|
||||||
|
for (let i = 0; i < numSeg; i++) {
|
||||||
|
const lace = buf[27 + i];
|
||||||
|
const part = buf.subarray(off, off + lace);
|
||||||
|
off += lace;
|
||||||
|
packetParts.push(Buffer.from(part));
|
||||||
|
if (lace < 255) {
|
||||||
|
const packet = Buffer.concat(packetParts);
|
||||||
|
packetParts = [];
|
||||||
|
if ((headerType & 0x01) === 0) {
|
||||||
|
// Not a continuation page → packet starts here
|
||||||
|
emitPacket(packet);
|
||||||
|
} else if (headerDone) {
|
||||||
|
// Continued page — packet body, emit directly
|
||||||
|
emitPacket(packet);
|
||||||
|
}
|
||||||
|
// (header packets on continuation pages are dropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf = buf.subarray(off);
|
||||||
|
if (buf.length === 0) return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.on("data", (chunk: Buffer) => {
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
processPages();
|
||||||
|
});
|
||||||
|
input.on("end", () => {
|
||||||
|
out.end();
|
||||||
|
});
|
||||||
|
input.on("error", () => {
|
||||||
|
out.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,13 +26,31 @@ export function software(
|
|||||||
} = {},
|
} = {},
|
||||||
): () => EncoderSet {
|
): () => EncoderSet {
|
||||||
const { x264, x265 } = opts;
|
const { x264, x265 } = opts;
|
||||||
const { preset: x264Preset = "superfast", tune: x264Tune = "film" } =
|
const { preset: x264Preset = "superfast", tune: x264Tune = "zerolatency" } =
|
||||||
x264 ?? {};
|
x264 ?? {};
|
||||||
const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {};
|
const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {};
|
||||||
return () => ({
|
return () => ({
|
||||||
H264: {
|
H264: {
|
||||||
name: "libx264",
|
name: "libx264",
|
||||||
options: ["-forced-idr 1", `-tune ${x264Tune}`, `-preset ${x264Preset}`],
|
// -profile:v baseline is REQUIRED: the SDP advertises
|
||||||
|
// profile-level-id=42e01f (constrained baseline) and Discord's
|
||||||
|
// receiver decodes with that profile. x264's default is High — a
|
||||||
|
// High-profile bitstream against a baseline SDP negotiation fails to
|
||||||
|
// decode → black GoLive tile (production bug, fixed 2026-08-12).
|
||||||
|
// zerolatency matches @dank074 (no lookahead — correct for live).
|
||||||
|
options: [
|
||||||
|
"-forced-idr 1",
|
||||||
|
"-profile:v baseline",
|
||||||
|
// repeat-headers: SPS/PPS inline BEFORE EVERY IDR, not just the
|
||||||
|
// first. Required for the NUT container path (NUT stores extradata
|
||||||
|
// in the header and `-c:v copy` remux loses it → decoder sees
|
||||||
|
// "non-existing PPS 0 referenced" → no picture) and lets Discord's
|
||||||
|
// decoder recover after any PLI/keyframe request mid-stream.
|
||||||
|
"-x264-params",
|
||||||
|
"repeat-headers=1",
|
||||||
|
`-tune ${x264Tune}`,
|
||||||
|
`-preset ${x264Preset}`,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
H265: {
|
H265: {
|
||||||
name: "libx265",
|
name: "libx265",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface StreamerClientLike {
|
|||||||
broadcast(data: { op: number; d: unknown }): void;
|
broadcast(data: { op: number; d: unknown }): void;
|
||||||
};
|
};
|
||||||
guilds?: {
|
guilds?: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- discord.js-selfbot client shape is dynamic
|
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot client shape is dynamic
|
||||||
fetch(id: string): Promise<any>;
|
fetch(id: string): Promise<any>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,19 @@ export class Streamer {
|
|||||||
this._client = client;
|
this._client = client;
|
||||||
// listen for gateway dispatch events
|
// listen for gateway dispatch events
|
||||||
this.client.on("raw", (packet) => {
|
this.client.on("raw", (packet) => {
|
||||||
this._gatewayEmitter.emit(packet.t, packet.d);
|
const t = packet.t as string;
|
||||||
|
if (
|
||||||
|
t === "STREAM_CREATE" ||
|
||||||
|
t === "STREAM_SERVER_UPDATE" ||
|
||||||
|
t === "VOICE_STATE_UPDATE" ||
|
||||||
|
t === "VOICE_SERVER_UPDATE"
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`[goLive:Streamer] raw dispatch ${t}`,
|
||||||
|
JSON.stringify(packet.d).slice(0, 220),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._gatewayEmitter.emit(t, packet.d);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +77,11 @@ export class Streamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendOpcode(code: number, data: unknown): void {
|
sendOpcode(code: number, data: unknown): void {
|
||||||
|
// Direct instrumentation — bypasses the bootstrap debug filter (which
|
||||||
|
// drops messages without [VOICE / [ffmpeg / error / stream).
|
||||||
|
console.log(
|
||||||
|
`[goLive:Streamer] sendOpcode op=${code} d=${JSON.stringify(data)}`,
|
||||||
|
);
|
||||||
this.client.ws.broadcast({ op: code, d: data });
|
this.client.ws.broadcast({ op: code, d: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +158,6 @@ export class Streamer {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.signalStream();
|
|
||||||
const {
|
const {
|
||||||
guildId: clientGuildId,
|
guildId: clientGuildId,
|
||||||
channelId: clientChannelId,
|
channelId: clientChannelId,
|
||||||
@@ -155,40 +171,85 @@ export class Streamer {
|
|||||||
clientUserId,
|
clientUserId,
|
||||||
clientChannelId,
|
clientChannelId,
|
||||||
(conn) => {
|
(conn) => {
|
||||||
|
clearTimeout(streamTimeout);
|
||||||
|
clearInterval(retryInterval);
|
||||||
resolve(conn);
|
resolve(conn);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.voiceConnection.streamConnection = streamConn;
|
this.voiceConnection.streamConnection = streamConn;
|
||||||
this._gatewayEmitter.on(
|
|
||||||
"STREAM_CREATE",
|
// Attach listeners BEFORE the first signal so a fast dispatch can't
|
||||||
(d: { stream_key: string; rtc_server_id: string }) => {
|
// be lost between signalStream() and listener registration.
|
||||||
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
const onStreamCreate = (d: {
|
||||||
if (
|
stream_key: string;
|
||||||
clientGuildId !== guildId ||
|
rtc_server_id: string;
|
||||||
clientChannelId !== channelId ||
|
}) => {
|
||||||
clientUserId !== userId
|
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
||||||
) {
|
if (
|
||||||
return;
|
clientGuildId !== guildId ||
|
||||||
}
|
clientChannelId !== channelId ||
|
||||||
streamConn.serverId = d.rtc_server_id;
|
clientUserId !== userId
|
||||||
streamConn.streamKey = d.stream_key;
|
) {
|
||||||
streamConn.setSession(session_id);
|
return;
|
||||||
},
|
}
|
||||||
);
|
streamConn.serverId = d.rtc_server_id;
|
||||||
this._gatewayEmitter.on(
|
streamConn.streamKey = d.stream_key;
|
||||||
"STREAM_SERVER_UPDATE",
|
streamConn.setSession(session_id);
|
||||||
(d: { stream_key: string; endpoint: string; token: string }) => {
|
};
|
||||||
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
const onStreamServerUpdate = (d: {
|
||||||
if (
|
stream_key: string;
|
||||||
clientGuildId !== guildId ||
|
endpoint: string;
|
||||||
clientChannelId !== channelId ||
|
token: string;
|
||||||
clientUserId !== userId
|
}) => {
|
||||||
) {
|
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
||||||
return;
|
if (
|
||||||
}
|
clientGuildId !== guildId ||
|
||||||
streamConn.setTokens(d.endpoint, d.token);
|
clientChannelId !== channelId ||
|
||||||
},
|
clientUserId !== userId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamConn.setTokens(d.endpoint, d.token);
|
||||||
|
};
|
||||||
|
this._gatewayEmitter.on("STREAM_CREATE", onStreamCreate);
|
||||||
|
this._gatewayEmitter.on("STREAM_SERVER_UPDATE", onStreamServerUpdate);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(streamTimeout);
|
||||||
|
clearInterval(retryInterval);
|
||||||
|
this._gatewayEmitter.removeListener("STREAM_CREATE", onStreamCreate);
|
||||||
|
this._gatewayEmitter.removeListener(
|
||||||
|
"STREAM_SERVER_UPDATE",
|
||||||
|
onStreamServerUpdate,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const streamTimeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Timed out waiting for STREAM_CREATE/STREAM_SERVER_UPDATE from Discord (stream handshake) — voice media session may not be active",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 12_000);
|
||||||
|
|
||||||
|
// Discord sometimes drops the STREAM_CREATE request silently (upstream
|
||||||
|
// issue #217/#219) — resend a few times instead of giving up after one.
|
||||||
|
let attempt = 0;
|
||||||
|
const retryInterval = setInterval(() => {
|
||||||
|
attempt += 1;
|
||||||
|
if (attempt >= 4) {
|
||||||
|
clearInterval(retryInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[goLive:Streamer] createStream: retrying STREAM_CREATE (attempt ${attempt + 1}/4)`,
|
||||||
|
);
|
||||||
|
this.signalStream();
|
||||||
|
}, 3_000);
|
||||||
|
console.log(
|
||||||
|
`[goLive:Streamer] createStream: sending STREAM_CREATE (attempt 1/4)`,
|
||||||
);
|
);
|
||||||
|
this.signalStream();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +260,7 @@ export class Streamer {
|
|||||||
const { guildId } = this.voiceConnection.streamConnection;
|
const { guildId } = this.voiceConnection.streamConnection;
|
||||||
if (!this.client.guilds) return;
|
if (!this.client.guilds) return;
|
||||||
const server = await this.client.guilds.fetch(guildId);
|
const server = await this.client.guilds.fetch(guildId);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- discord.js-selfbot dynamic
|
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot dynamic
|
||||||
(server as any).members.me?.voice?.postPreview(data);
|
(server as any).members.me?.voice?.postPreview(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +302,16 @@ export class Streamer {
|
|||||||
channelId: channel_id,
|
channelId: channel_id,
|
||||||
botId: user_id,
|
botId: user_id,
|
||||||
} = this.voiceConnection;
|
} = this.voiceConnection;
|
||||||
|
// Un-deafen before requesting the stream (mimic real client). Do NOT
|
||||||
|
// set self_video: true — that flips on the bot's camera in Discord
|
||||||
|
// (visible to everyone); screen share should not enable the camera.
|
||||||
|
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
||||||
|
guild_id,
|
||||||
|
channel_id,
|
||||||
|
self_mute: false,
|
||||||
|
self_deaf: false,
|
||||||
|
self_video: false,
|
||||||
|
});
|
||||||
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
|
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
|
||||||
type,
|
type,
|
||||||
guild_id,
|
guild_id,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export class WebRtcConnWrapper {
|
|||||||
private _audioTrack: NativeTrack | null = null;
|
private _audioTrack: NativeTrack | null = null;
|
||||||
private _videoTrack: NativeTrack | null = null;
|
private _videoTrack: NativeTrack | null = null;
|
||||||
private _videoCodec: WebRtcVideoCodec | null = null;
|
private _videoCodec: WebRtcVideoCodec | null = null;
|
||||||
|
private _videoFrameLog = 0;
|
||||||
/** Assigned by BaseMediaConnection to send the gathered SDP to Discord. */
|
/** Assigned by BaseMediaConnection to send the gathered SDP to Discord. */
|
||||||
onLocalDescription: ((sdp: string) => void) | null = null;
|
onLocalDescription: ((sdp: string) => void) | null = null;
|
||||||
|
|
||||||
@@ -114,7 +115,15 @@ export class WebRtcConnWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendVideoFrame(frame: Buffer, frametime: number): void {
|
sendVideoFrame(frame: Buffer, frametime: number): void {
|
||||||
if (!this.ready || !this._videoTrack) return;
|
if (!this.ready || !this._videoTrack) {
|
||||||
|
if (this._videoFrameLog === 0) {
|
||||||
|
console.log(
|
||||||
|
`[goLive:WebRtc] sendVideoFrame DROPPED ready=${this.ready} track=${this._videoTrack !== null}`,
|
||||||
|
);
|
||||||
|
this._videoFrameLog++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const clockRate = CodecPayloadType[this._videoCodec ?? "H264"].clockRate;
|
const clockRate = CodecPayloadType[this._videoCodec ?? "H264"].clockRate;
|
||||||
if (this._videoCodec === "H264") {
|
if (this._videoCodec === "H264") {
|
||||||
let spsRewritten = false;
|
let spsRewritten = false;
|
||||||
@@ -157,6 +166,12 @@ export class WebRtcConnWrapper {
|
|||||||
}
|
}
|
||||||
this._videoTrack.sendFrame(frame);
|
this._videoTrack.sendFrame(frame);
|
||||||
this._videoTrack.addTimestamp(Math.round((frametime * clockRate) / 1000));
|
this._videoTrack.addTimestamp(Math.round((frametime * clockRate) / 1000));
|
||||||
|
this._videoFrameLog++;
|
||||||
|
if (this._videoFrameLog === 1 || this._videoFrameLog % 30 === 0) {
|
||||||
|
console.log(
|
||||||
|
`[goLive:WebRtc] sendVideoFrame #${this._videoFrameLog} bytes=${frame.length} ready=${this.ready}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPacketizer(videoCodec: string): void {
|
setPacketizer(videoCodec: string): void {
|
||||||
@@ -164,6 +179,9 @@ export class WebRtcConnWrapper {
|
|||||||
throw new Error("WebRTC connection not ready");
|
throw new Error("WebRTC connection not ready");
|
||||||
}
|
}
|
||||||
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
|
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
|
||||||
|
console.log(
|
||||||
|
`[goLive:WebRtc] setPacketizer(${videoCodec}) audioSsrc=${audioSsrc} videoSsrc=${videoSsrc} rtxSsrc=${this.mediaConnection.webRtcParams.rtxSsrc}`,
|
||||||
|
);
|
||||||
this._videoCodec = normalizeVideoCodec(videoCodec);
|
this._videoCodec = normalizeVideoCodec(videoCodec);
|
||||||
// Audio packetizer: opus 120 @ 48kHz, playout delay ext id 5 (like @dank074)
|
// Audio packetizer: opus 120 @ 48kHz, playout delay ext id 5 (like @dank074)
|
||||||
this._audioTrack?.setPacketizer(
|
this._audioTrack?.setPacketizer(
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { PassThrough, type Readable } from "node:stream";
|
import { PassThrough, type Readable } from "node:stream";
|
||||||
|
import { AudioStream } from "./AudioStream.js";
|
||||||
import { demux } from "./Demuxer.js";
|
import { demux } from "./Demuxer.js";
|
||||||
import { type EncoderSettings, Encoders } from "./Encoders.js";
|
import { type EncoderSettings, Encoders } from "./Encoders.js";
|
||||||
import { VideoStream } from "./VideoStream.js";
|
import { VideoStream } from "./VideoStream.js";
|
||||||
@@ -25,6 +28,8 @@ export interface PrepareStreamResult {
|
|||||||
height: number;
|
height: number;
|
||||||
frameRate?: number;
|
frameRate?: number;
|
||||||
includeAudio: boolean;
|
includeAudio: boolean;
|
||||||
|
/** Container the encoder muxes to: "nut" (audio-capable) or "h264" (raw). */
|
||||||
|
format: "nut" | "h264";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFiniteNonZero(n: unknown): n is number {
|
function isFiniteNonZero(n: unknown): n is number {
|
||||||
@@ -37,6 +42,25 @@ const DEFAULT_HEADERS = {
|
|||||||
Connection: "keep-alive",
|
Connection: "keep-alive",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Resolve ffmpeg binary (env override → PATH → Nix store ffmpeg-headless). */
|
||||||
|
function resolveFfmpeg(): string {
|
||||||
|
if (process.env.FFMPEG_PATH && existsSync(process.env.FFMPEG_PATH)) {
|
||||||
|
return process.env.FFMPEG_PATH;
|
||||||
|
}
|
||||||
|
const store = "/nix/store";
|
||||||
|
if (existsSync(store)) {
|
||||||
|
const entries = readdirSync(store);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.includes("ffmpeg-headless-")) continue;
|
||||||
|
const candidate = join(store, entry, "bin", "ffmpeg");
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "ffmpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const FFMPEG_BIN = resolveFfmpeg();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* prepareStream — build an ffmpeg command (as spawn args + PassThrough output)
|
* prepareStream — build an ffmpeg command (as spawn args + PassThrough output)
|
||||||
* that transcodes the input into a pipe we can demux. Mirrors @dank074's
|
* that transcodes the input into a pipe we can demux. Mirrors @dank074's
|
||||||
@@ -132,6 +156,11 @@ export function prepareStream(
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`Encoder settings not specified for ${mergedOptions.videoCodec}`,
|
`Encoder settings not specified for ${mergedOptions.videoCodec}`,
|
||||||
);
|
);
|
||||||
|
// Encoder options are declared as single strings like "-forced-idr 1";
|
||||||
|
// spawn needs each flag and value as separate argv entries.
|
||||||
|
const encOptions = enc.options.flatMap((opt) =>
|
||||||
|
opt.split(/\s+/).filter(Boolean),
|
||||||
|
);
|
||||||
args.push(
|
args.push(
|
||||||
"-b:v",
|
"-b:v",
|
||||||
`${mergedOptions.bitrateVideo}k`,
|
`${mergedOptions.bitrateVideo}k`,
|
||||||
@@ -147,15 +176,24 @@ export function prepareStream(
|
|||||||
"expr:gte(t,n_forced*1)",
|
"expr:gte(t,n_forced*1)",
|
||||||
"-c:v",
|
"-c:v",
|
||||||
enc.name,
|
enc.name,
|
||||||
...enc.options,
|
...encOptions,
|
||||||
...(enc.globalOptions ?? []),
|
...(enc.globalOptions ?? []).flatMap((opt) =>
|
||||||
|
opt.split(/\s+/).filter(Boolean),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio
|
// Audio: transcode to libopus. NUT muxer on stdout carries video (H264
|
||||||
|
// AnnexB) + audio (Ogg Opus) as ONE stream into the Demuxer, which re-splits
|
||||||
|
// them via a child ffmpeg -f nut -i pipe:0 -c:v copy -f h264 pipe:1 ... . The
|
||||||
|
// Demuxer's start-code scan runs on THAT child ffmpeg's stdout (pure H264),
|
||||||
|
// NOT on NUT — so NAL type 5 (IDR) is parsed correctly. (Outputting raw
|
||||||
|
// h264+opus on two pipes directly was tried and broke: the audio pipe was
|
||||||
|
// never attached to the demuxer's input, so audio RTP never flowed.)
|
||||||
if (mergedOptions.includeAudio) {
|
if (mergedOptions.includeAudio) {
|
||||||
args.push("-map", "0:a:0?");
|
|
||||||
args.push(
|
args.push(
|
||||||
|
"-map",
|
||||||
|
"0:a:0?",
|
||||||
"-c:a",
|
"-c:a",
|
||||||
"libopus",
|
"libopus",
|
||||||
"-b:a",
|
"-b:a",
|
||||||
@@ -169,20 +207,37 @@ export function prepareStream(
|
|||||||
args.push("-an");
|
args.push("-an");
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push(...mergedOptions.customFfmpegFlags);
|
// NUT muxer carries video+audio; the raw h264 muxer cannot ("h264 muxer
|
||||||
args.push("-f", "h264", "pipe:1");
|
// does not support any stream of type audio" -> header write fails ->
|
||||||
|
// empty stdout -> black tile). Audio delivery requires NUT.
|
||||||
|
const outFormat = mergedOptions.includeAudio ? "nut" : "h264";
|
||||||
|
args.push("-f", outFormat, "pipe:1");
|
||||||
|
|
||||||
const isUrl = typeof input === "string";
|
const isUrl = typeof input === "string";
|
||||||
const proc: ChildProcess = isUrl
|
const proc: ChildProcess = isUrl
|
||||||
? spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] })
|
? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] })
|
||||||
: spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"] });
|
: spawn(FFMPEG_BIN, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
|
||||||
if (proc.stdin && !isUrl) {
|
if (proc.stdin && !isUrl) {
|
||||||
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
|
// Race guard: the merge ffmpeg may have already exited (transient 403
|
||||||
input.on("end", () => proc.stdin?.end());
|
// or stream death) before this function attaches its listeners — the
|
||||||
input.on("error", () => proc.stdin?.destroy());
|
// input's 'end'/'error' events then fire into the void and the encoder
|
||||||
|
// stdin NEVER receives EOF, leaving an encoder that waits forever and a
|
||||||
|
// screen share that shows a black tile with zero frames. Check the
|
||||||
|
// terminal state eagerly and EOF the encoder immediately.
|
||||||
|
if (input.readableEnded || input.destroyed) {
|
||||||
|
proc.stdin.end();
|
||||||
|
} else {
|
||||||
|
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
|
||||||
|
input.on("end", () => proc.stdin?.end());
|
||||||
|
input.on("error", () => proc.stdin?.destroy());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safety: proc may error before playStream attaches a demux listener on
|
||||||
|
// `output`. A no-op listener here prevents an unhandled 'error' event
|
||||||
|
// on the PassThrough from crashing the gateway on ffmpeg spawn failure.
|
||||||
|
output.on("error", () => {});
|
||||||
proc.stdout?.pipe(output);
|
proc.stdout?.pipe(output);
|
||||||
proc.stderr?.on("data", () => {
|
proc.stderr?.on("data", () => {
|
||||||
/* swallow ffmpeg stderr */
|
/* swallow ffmpeg stderr */
|
||||||
@@ -210,6 +265,7 @@ export function prepareStream(
|
|||||||
height: mergedOptions.height,
|
height: mergedOptions.height,
|
||||||
frameRate: mergedOptions.frameRate,
|
frameRate: mergedOptions.frameRate,
|
||||||
includeAudio: !!mergedOptions.includeAudio,
|
includeAudio: !!mergedOptions.includeAudio,
|
||||||
|
format: outFormat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,15 +290,28 @@ export async function playStream(
|
|||||||
options: PlayStreamOptions = {},
|
options: PlayStreamOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const conn = await streamer.createStream();
|
const conn = await streamer.createStream();
|
||||||
|
console.log("[goLive:playStream] createStream resolved");
|
||||||
|
|
||||||
const { video, close: demuxClose } = await demux(prepared.output, {
|
const {
|
||||||
format: options.format ?? "nut",
|
video,
|
||||||
|
audio,
|
||||||
|
close: demuxClose,
|
||||||
|
} = await demux(prepared.output, {
|
||||||
|
format: options.format ?? prepared.format ?? "nut",
|
||||||
|
frameRate:
|
||||||
|
typeof options.frameRate === "number" ? options.frameRate : undefined,
|
||||||
});
|
});
|
||||||
|
console.log(
|
||||||
|
`[goLive:playStream] demux done codec=${video?.codecName ?? "?"} ${video?.width ?? 0}x${video?.height ?? 0} fps=${video ? video.framerate_num / video.framerate_den || 30 : 30} audio=${audio?.codecName ?? "none"}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!video) throw new Error("No video stream in media");
|
if (!video) throw new Error("No video stream in media");
|
||||||
|
|
||||||
conn.setPacketizer(video.codecName);
|
conn.setPacketizer(video.codecName);
|
||||||
conn.mediaConnection.setSpeaking(true);
|
conn.mediaConnection.setSpeaking(true);
|
||||||
|
console.log(
|
||||||
|
`[goLive:playStream] setPacketizer(${video.codecName}) + setSpeaking done`,
|
||||||
|
);
|
||||||
|
|
||||||
const w =
|
const w =
|
||||||
typeof options.width === "function"
|
typeof options.width === "function"
|
||||||
@@ -267,6 +336,19 @@ export async function playStream(
|
|||||||
const vStream = new VideoStream(conn);
|
const vStream = new VideoStream(conn);
|
||||||
video.stream.pipe(vStream);
|
video.stream.pipe(vStream);
|
||||||
|
|
||||||
|
// Audio: Discord's GoLive pipeline expects RTP on the audio SSRC too —
|
||||||
|
// a video-only stream (zero audio packets) shows a static tile/thumbnail
|
||||||
|
// instead of live video. Pipe opus frames from the demuxer (silence is
|
||||||
|
// injected at the encoder when the source has no audio track).
|
||||||
|
let aStream: AudioStream | undefined;
|
||||||
|
if (audio) {
|
||||||
|
aStream = new AudioStream(conn);
|
||||||
|
audio.stream.pipe(aStream);
|
||||||
|
console.log(
|
||||||
|
`[goLive:playStream] audio stream attached (${audio.codecName})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
try {
|
try {
|
||||||
prepared.command.kill("SIGTERM");
|
prepared.command.kill("SIGTERM");
|
||||||
@@ -282,14 +364,81 @@ export async function playStream(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
// First-frame watchdog: if the encoder never delivers a single frame
|
||||||
vStream.once("finish", () => {
|
// (dead merge input, empty stream, codec mismatch), fail fast instead of
|
||||||
cleanup();
|
// "playing" a black tile forever. The demuxer resolves with fallback
|
||||||
|
// metadata even when no frame ever arrives, so this timeout is the only
|
||||||
|
// place that detects "started but nothing flowing".
|
||||||
|
let firstFrameTimer: NodeJS.Timeout | null = null;
|
||||||
|
let gotFirstFrame = false;
|
||||||
|
const firstFrame = new Promise<void>((resolve, reject) => {
|
||||||
|
firstFrameTimer = setTimeout(() => {
|
||||||
|
if (!gotFirstFrame) {
|
||||||
|
cleanup();
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"No video frames within 10s of stream start — input stream failed",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
video.stream.once("data", () => {
|
||||||
|
gotFirstFrame = true;
|
||||||
|
if (firstFrameTimer) clearTimeout(firstFrameTimer);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = (fn: () => void) => () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (firstFrameTimer) clearTimeout(firstFrameTimer);
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
vStream.once("finish", () => {
|
||||||
|
settle(() => {
|
||||||
|
cleanup();
|
||||||
|
if (!gotFirstFrame) {
|
||||||
|
reject(new Error("Screen video stream ended without any frame"));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
vStream.once("error", () => {
|
vStream.once("error", () => {
|
||||||
cleanup();
|
settle(() => {
|
||||||
resolve();
|
cleanup();
|
||||||
|
if (!gotFirstFrame) {
|
||||||
|
reject(new Error("Screen video stream errored before first frame"));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
// The stream may end without ever producing a frame (input was
|
||||||
|
// silently dead) — surface that instead of resolving "successfully".
|
||||||
|
video.stream.once("end", () => {
|
||||||
|
settle(() => {
|
||||||
|
cleanup();
|
||||||
|
if (!gotFirstFrame) {
|
||||||
|
reject(new Error("Screen video stream ended before any frame"));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
// Watchdog timeout: no frame arrived within 10s — fail fast instead of
|
||||||
|
// "playing" a black tile forever. cleanup() kills the encoder so the
|
||||||
|
// vStream finish/error handlers above still fire, but the settled guard
|
||||||
|
// ensures this rejection wins.
|
||||||
|
firstFrame.catch((err) => {
|
||||||
|
settle(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
acquireMediaAnalysisLock,
|
acquireMediaAnalysisLock,
|
||||||
computeImagePhash,
|
|
||||||
deleteCachedMediaAnalysis,
|
deleteCachedMediaAnalysis,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
setCachedMediaAnalysis,
|
setCachedMediaAnalysis,
|
||||||
|
|||||||
@@ -7,28 +7,22 @@
|
|||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import {
|
import {
|
||||||
acquireMediaAnalysisLock,
|
acquireMediaAnalysisLock,
|
||||||
computeImagePhash,
|
|
||||||
deleteCachedMediaAnalysis,
|
deleteCachedMediaAnalysis,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
getCachedMediaByPhash,
|
|
||||||
makeCustomEmojiCacheKey,
|
makeCustomEmojiCacheKey,
|
||||||
makeImageCacheKey,
|
makeImageCacheKey,
|
||||||
makeStickerCacheKey,
|
makeStickerCacheKey,
|
||||||
upsertCachedMediaAnalysis,
|
upsertCachedMediaAnalysis,
|
||||||
upsertCachedMediaByPhash,
|
|
||||||
} from "./textCacheStore.js";
|
} from "./textCacheStore.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
acquireMediaAnalysisLock,
|
acquireMediaAnalysisLock,
|
||||||
computeImagePhash,
|
|
||||||
deleteCachedMediaAnalysis,
|
deleteCachedMediaAnalysis,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
getCachedMediaByPhash,
|
|
||||||
makeCustomEmojiCacheKey,
|
makeCustomEmojiCacheKey,
|
||||||
makeImageCacheKey,
|
makeImageCacheKey,
|
||||||
makeStickerCacheKey,
|
makeStickerCacheKey,
|
||||||
upsertCachedMediaAnalysis,
|
upsertCachedMediaAnalysis,
|
||||||
upsertCachedMediaByPhash,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Convenience alias for upsertCachedMediaAnalysis. */
|
/** Convenience alias for upsertCachedMediaAnalysis. */
|
||||||
|
|||||||
@@ -335,6 +335,26 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
'{"results":[{"message_id":"31313","status":"flagged","flags":["conflict_instigation","sara"],"score":0.95,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["gw sih dukung palestina"],"analysis":"Segala diskusi Israel/Palestina/Yahudi dilarang total — tidak ada debat, dukungan, atau berita. Dihapus."}]}',
|
'{"results":[{"message_id":"31313","status":"flagged","flags":["conflict_instigation","sara"],"score":0.95,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["gw sih dukung palestina"],"analysis":"Segala diskusi Israel/Palestina/Yahudi dilarang total — tidak ada debat, dukungan, atau berita. Dihapus."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Physics / Technology Discussions (false positive prevention) ──
|
||||||
|
{
|
||||||
|
id: "32",
|
||||||
|
title: "Diskusi fisika/kinetik dalam konteks teknis (AMAN, bukan ancaman)",
|
||||||
|
input:
|
||||||
|
"[target] id=32323 user=physics_student: Cukup cuman tubuh manusia vs gravitasi. Konsep energy conservation di sini penting buat analisis statis.",
|
||||||
|
output:
|
||||||
|
'{"results":[{"message_id":"32323","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Diskusi fisika teknis tentang kinetik dan gravitasi dalam konteks analisis statis – tidak ada ancaman atau konten melanggar. Penggunaan istilah fisika untuk perhitungan teknis adalah hal wajar."}]}',
|
||||||
|
modes: ["text", "mixed"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "33",
|
||||||
|
title: "Diskusi drone/senjata dalam konteks teknis (AMAN, bukan ancaman)",
|
||||||
|
input:
|
||||||
|
"[target] id=33333 user=engineer: Pengirim menyiratkan penggunaan energi kinetik dari jatuh (tubuh manusia vs gravitasi) sebagai metode untuk 'menetralisir' target dalam konteks diskusi senjata drone sebelumnya.",
|
||||||
|
output:
|
||||||
|
'{"results":[{"message_id":"33333","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.9,"recommended_action":"none","evidence":[],"analysis":"Diskusi teknis tentang drone dan aplikasi fisika dalam konteks engineering – tidak ada ajuan aksi atau ancaman nyata. Penggunaan istilah senjata dalam konteks diskusi teori adalah hal wajar."}]}',
|
||||||
|
modes: ["text", "mixed"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Derive per-mode strings from the single ALL_EXAMPLES array (zero duplication)
|
// Derive per-mode strings from the single ALL_EXAMPLES array (zero duplication)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Data konteks tersedia: <user_profiles> (peta ringkasan kepribadian, di pesan USE
|
|||||||
Gunakan untuk personalisasi analysis, tapi:
|
Gunakan untuk personalisasi analysis, tapi:
|
||||||
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran.
|
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran.
|
||||||
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis.
|
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis.
|
||||||
- <user_history> (kutipan pesan yang pernah di-flag) = pola pelanggaran lama. Gunakan untuk mendeteksi PENGULANGAN (mis. spam link yang sama, provokasi berulang), tapi JANGAN memflag pesan bersih hanya karena riwayat.
|
- <user_history> (kutipan pesan yang pernah di-flag) = pola pelanggaran lama. Gunakan untuk mendeteksi PENGULANGAN KEKONSISTEN (spam link yang SAMA, provokasi yang MENGULANG KONTEN NYATA YANG SAMA). JANGAN pernah gunakan history untuk "menginterpretasi ulang" pesan bersih yang TERPISAH DARI riwayat. Setiap pesan BARU dinilai TERSAMBUNG (standalone). Jika tidak ada pola pengulangan yang jelas → CLEAN. Contoh: Jika sebelumnya ada pesan dengan link scam.example.com yang di-flag, dan pesan baru juga ada link scam.example.com → FLAG. Tapi jika pesan baru tentang "energi kinetik dari jatuh" tanpa link yang sama → CLEAN walaupun ada history lain.
|
||||||
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik.
|
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik.
|
||||||
- Channel culture coding/teknis → pesan teknis lebih wajar; channel santai → slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata.
|
- Channel culture coding/teknis → pesan teknis lebih wajar; channel santai → slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const SYSTEM_RULES = `Kamu adalah asisten moderasi konten untuk server Di
|
|||||||
## Normalisasi & Pertahanan Lintas Bahasa (WAJIB)
|
## Normalisasi & Pertahanan Lintas Bahasa (WAJIB)
|
||||||
1. Campuran bahasa (Inggris/Indonesia/daerah) WAJIB dinormalisasi mental ke Bahasa Indonesia sebelum menilai intent. Jangan longgar hanya karena sintaksis campur (Polyglot Obfuscation).
|
1. Campuran bahasa (Inggris/Indonesia/daerah) WAJIB dinormalisasi mental ke Bahasa Indonesia sebelum menilai intent. Jangan longgar hanya karena sintaksis campur (Polyglot Obfuscation).
|
||||||
2. Lakukan Named Entity Recognition agresif — nama orang/karakter (mis. "ren" setelah kata archaic "diagem") tetap dikenali sebagai nama.
|
2. Lakukan Named Entity Recognition agresif — nama orang/karakter (mis. "ren" setelah kata archaic "diagem") tetap dikenali sebagai nama.
|
||||||
|
3. <term_glossary> (bila ada) = definisi kata/slang/jargon yang tidak umum. Baca dulu arti kata yang tidak kamu kenal dari sana — jangan menebak dari bunyi/kemiripan. Kata yang tampak mencurigakan namun ternyata bermakna netral di glossary = AMAN; kata asing yang ternyata vulgar/terlarang di glossary = FLAG.
|
||||||
|
|
||||||
## Aturan Umum (AMAN — jangan flag)
|
## Aturan Umum (AMAN — jangan flag)
|
||||||
- Slang: anjay, wkwk, gws, gaskeun, santuy, njir, baka, woy/woi, hadeh, astaga = AMAN.
|
- Slang: anjay, wkwk, gws, gaskeun, santuy, njir, baka, woy/woi, hadeh, astaga = AMAN.
|
||||||
@@ -29,15 +30,19 @@ export const SYSTEM_RULES = `Kamu adalah asisten moderasi konten untuk server Di
|
|||||||
- Ekspresi religius (Astaghfirullah, Alhamdulillah, Subhanallah, Allahuakbar, MasyaAllah, Bismillah, InsyaAllah, Laa ilaha illallah + varian all-caps) = DOA NORMAL, bukan vulgar. AMAN.
|
- Ekspresi religius (Astaghfirullah, Alhamdulillah, Subhanallah, Allahuakbar, MasyaAllah, Bismillah, InsyaAllah, Laa ilaha illallah + varian all-caps) = DOA NORMAL, bukan vulgar. AMAN.
|
||||||
- Discord custom emoji (<:hadeh:123>) = ekspresi, bukan pelanggaran teks.
|
- Discord custom emoji (<:hadeh:123>) = ekspresi, bukan pelanggaran teks.
|
||||||
- Makian pada entitas eksternal (game, dev, perusahaan, benda mati: "game ini ampas") = AMAN. Harassment/hate_speech HANYA untuk anggota/kelompok server secara personal.
|
- Makian pada entitas eksternal (game, dev, perusahaan, benda mati: "game ini ampas") = AMAN. Harassment/hate_speech HANYA untuk anggota/kelompok server secara personal.
|
||||||
|
- **Diskusi fisika, teknik, atau engineering dalam konteks teknis** (kinetik, gravitasi, energi, drone, senjata, drone warfare, physics simulations, CAD, CNC, 3D printing, robotics, aerospace, aerodynamika) = AMAN. Penggunaan istilah teknis untuk perhitungan atau analisis bukan ancaman. JANGAN flag hanya karena istilah "senjata" atau "drone" dalam konteks diskusi teori teknis. Flag HANYA jika ada ajuan aksi eksplisit atau ancaman nyata terarah.
|
||||||
|
- **Riwayat pengguna dengan pelanggaran sebelumnya** tidak boleh memengaruhi penilaian pesan bersih yang TERPISAH dan tidak mengandung pelanggaran aktual. Setiap pesan dinilai berdasarkan ISINYA SENDIRI.
|
||||||
|
|
||||||
## Zero Tolerance — Vulgaritas Anatomi/Seksual
|
## Zero Tolerance — Vulgaritas Anatomi/Seksual
|
||||||
Kata alat kelamin/anatomi seksual (kontol, memek, titten, tit, dick) atau istilah seksual eksplisit WAJIB di-flag sebagai vulgar_language/sexual_content — TANPA pengecualian bercanda, slang, atau "santai".
|
Kata alat kelamin/anatomi seksual (kontol, memek, titten, tit, dick) atau istilah seksual eksplisit WAJIB di-flag sebagai vulgar_language/sexual_content — TANPA pengecualian bercanda, slang, atau "santai".
|
||||||
|
|
||||||
## Nilai Server — Diskriminasi
|
## Nilai Server — Diskriminasi
|
||||||
- Seksisme ("dasar perempuan", "logika cewek") → hate_speech (umum) / harassment (terarah).
|
-Ketika sesuatu yang melanggar terjadi di channel, flag jika relevan. Setiap pesan dinilai BERDASARKAN ISINYA SENDIRI, bukan sekadar histori pengguna.
|
||||||
- Ageisme ("dasar bocil", "tau aja lo tua") → hate_speech / harassment.
|
-Seksisme ("dasar perempuan", "logika cewek") → hate_speech (umum) / harassment (terarah).
|
||||||
- Diskriminasi fisik ("gendut", "iteman", "cungkring") → harassment jika terarah.
|
-Ageisme ("dasar bocil", "tau aja lo tua") → hate_speech / harassment.
|
||||||
- Serangan personal, penghinaan, merendahkan = tidak ditoleransi. Perbedaan pendapat wajar.
|
-Diskriminasi fisik ("gendut", "iteman", "cungkring") → harassment jika terarah.
|
||||||
|
-Serangan personal, penghinaan, merendahkan = tidak ditoleransi. Perbedaan pendapat wajar.
|
||||||
|
+**PESAN DINILAI SECARA STANDALONE:** Setiap pesan baru dinilai BERDASARKAN ISINYA SENDIRI. <user_history> (jika ada) HANYA untuk mendeteksi POLA PENGULANGAN dengan JAMAK (spam link yang SAMA, provokasi berulang yang MENGANDALKAN KONTEN YANG SAMA). JANGAN gunakan history untuk "menginterpretasi ulang" pesan bersih yang TERPISAH DARI riwayat pelanggaran sebelumnya. Jika pesan tidak mengandung unsur yang BERPANDUAN PADA riwayat → tetap CLEAN.
|
||||||
|
|
||||||
## LARANGAN BERAT (ZERO TOLERANCE)
|
## LARANGAN BERAT (ZERO TOLERANCE)
|
||||||
- **LGBT:** Segala promosi, diskusi, pengakuan orientasi, coming out, atau curhat personal tentang LGBT WAJIB di-flag "sexual_deviation". Tidak ada pengecualian.
|
- **LGBT:** Segala promosi, diskusi, pengakuan orientasi, coming out, atau curhat personal tentang LGBT WAJIB di-flag "sexual_deviation". Tidak ada pengecualian.
|
||||||
@@ -73,6 +78,7 @@ RENDAH: harassment, vulgar_language terarah, offensive_username (Scunthorpe: "Sa
|
|||||||
|
|
||||||
## Web Sebagai Bukti Utama
|
## Web Sebagai Bukti Utama
|
||||||
- <web_searches> ADALAH BUKTI UTAMA. Jika ada, WAJIB pakai hasilnya (hentai/scam/narkoba → flag; aman → clean). JANGAN abaikan. Jika tidak ada → gunakan pengetahuan internal.
|
- <web_searches> ADALAH BUKTI UTAMA. Jika ada, WAJIB pakai hasilnya (hentai/scam/narkoba → flag; aman → clean). JANGAN abaikan. Jika tidak ada → gunakan pengetahuan internal.
|
||||||
|
- <term_glossary> = REFERENSI ARTI KATA, bukan bukti pelanggaran. Dipakai untuk memahami istilah yang tidak dikenal sebelum memutuskan.
|
||||||
- Prioritas bukti: <web_searches> > <web_content> > <media_analysis> > pengetahuan internal. <web_content> (URL fetch): gunakan isi, jangan flag hanya dari domain name.
|
- Prioritas bukti: <web_searches> > <web_content> > <media_analysis> > pengetahuan internal. <web_content> (URL fetch): gunakan isi, jangan flag hanya dari domain name.
|
||||||
|
|
||||||
## Pohon Keputusan
|
## Pohon Keputusan
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
|||||||
`- <conversation_context> = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` +
|
`- <conversation_context> = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` +
|
||||||
`- <user_profiles> = peta ringkasan kepribadian per user_id (attr as_of = kapan profil terakhir dibuat — profil lama mungkin tidak mencerminkan perilaku terkini); setiap <message> merujuk lewat <user_profile_ref user_id="..."/>.\n` +
|
`- <user_profiles> = peta ringkasan kepribadian per user_id (attr as_of = kapan profil terakhir dibuat — profil lama mungkin tidak mencerminkan perilaku terkini); setiap <message> merujuk lewat <user_profile_ref user_id="..."/>.\n` +
|
||||||
`- <web_searches> / <web_content> = bukti web (lihat "Web Sebagai Bukti Utama").\n` +
|
`- <web_searches> / <web_content> = bukti web (lihat "Web Sebagai Bukti Utama").\n` +
|
||||||
|
`- <term_glossary> = kamus istilah: definisi kata/slang/jargon yang jarang dikenal (hasil pencarian Wikipedia via SearXNG). Gunakan untuk memahami arti kata yang tidak kamu kenal — JANGAN menebak atau mengarang arti.\n` +
|
||||||
`- <messages_to_analyze> = pesan-pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user (nama server), time (ISO — kapan pesan dikirim), repetitions (N = teks pendek sama muncul N kali di batch — sinyal spam), bot (true jika dari bot), edited (true jika konten adalah hasil edit setelah posting).`,
|
`- <messages_to_analyze> = pesan-pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user (nama server), time (ISO — kapan pesan dikirim), repetitions (N = teks pendek sama muncul N kali di batch — sinyal spam), bot (true jika dari bot), edited (true jika konten adalah hasil edit setelah posting).`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
||||||
|
import { config } from "../../shared/config/config.js";
|
||||||
|
|
||||||
const log = createChildLogger("searxng-search");
|
const log = createChildLogger("searxng-search");
|
||||||
|
|
||||||
const SEARXNG_BASE_URL = "https://searxng.imrnes.team";
|
const SEARXNG_BASE_URL = config.SEARXNG_BASE_URL;
|
||||||
const MAX_RESULTS = 3;
|
const MAX_RESULTS = 3;
|
||||||
const TIMEOUT_MS = 8000;
|
const TIMEOUT_MS = 8000;
|
||||||
const CACHE_TTL = 86400; // 24 hours
|
const CACHE_TTL = 86400; // 24 hours
|
||||||
@@ -12,6 +13,42 @@ const CACHE_PREFIX = "searxng:";
|
|||||||
|
|
||||||
let redis: Redis | null = null;
|
let redis: Redis | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes the shared SearXNG Redis connection so other modules (e.g. the
|
||||||
|
* term glossary) reuse the same connection and cache prefix instead of
|
||||||
|
* opening their own. Returns null when Redis is unavailable.
|
||||||
|
*/
|
||||||
|
export function getSearxngRedis(): Redis | null {
|
||||||
|
return redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a namespaced SearXNG cache key (shared across modules). */
|
||||||
|
export function makeSearxngCacheKey(namespace: string, key: string): string {
|
||||||
|
return `${CACHE_PREFIX}${namespace}:${key.toLowerCase().trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a value from the SearXNG Redis cache; null on miss/unavailable. */
|
||||||
|
export async function searxngCacheGet(key: string): Promise<string | null> {
|
||||||
|
if (!redis) return null;
|
||||||
|
try {
|
||||||
|
return await redis.get(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a value to the SearXNG Redis cache, fire-and-forget. */
|
||||||
|
export function searxngCacheSet(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
ttlSeconds: number,
|
||||||
|
): void {
|
||||||
|
if (!redis) return;
|
||||||
|
redis.setex(key, ttlSeconds, value).catch(() => {
|
||||||
|
// Cache write failed silently
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Redis connection for SearXNG cache.
|
* Initialize Redis connection for SearXNG cache.
|
||||||
* Safe to call multiple times — only creates one connection.
|
* Safe to call multiple times — only creates one connection.
|
||||||
@@ -51,19 +88,26 @@ export interface SearxngResult {
|
|||||||
/**
|
/**
|
||||||
* Search SearXNG for a query and return structured results.
|
* Search SearXNG for a query and return structured results.
|
||||||
* Uses Redis cache when available — same query within 24h returns cached results.
|
* Uses Redis cache when available — same query within 24h returns cached results.
|
||||||
|
*
|
||||||
|
* @param engines Optional comma-separated SearXNG engine list to constrain
|
||||||
|
* the search (e.g. "wikipedia"). When set, results are cached under a
|
||||||
|
* separate cache namespace so engine-specific results never collide.
|
||||||
*/
|
*/
|
||||||
export async function searchSearxng(
|
export async function searchSearxng(
|
||||||
query: string,
|
query: string,
|
||||||
category: "general" | "news" | "science" = "general",
|
category: "general" | "news" | "science" = "general",
|
||||||
|
engines?: string,
|
||||||
|
timeoutMs: number = TIMEOUT_MS,
|
||||||
): Promise<SearxngResult[]> {
|
): Promise<SearxngResult[]> {
|
||||||
const cacheKey = `${CACHE_PREFIX}${category}:${query.toLowerCase().trim()}`;
|
const engineNs = engines ? `eng:${engines}` : "auto";
|
||||||
|
const cacheKey = makeSearxngCacheKey(`${category}:${engineNs}`, query);
|
||||||
|
|
||||||
// Try cache first
|
// Try cache first
|
||||||
if (redis) {
|
if (redis) {
|
||||||
try {
|
try {
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
log.debug({ query, category }, "SearXNG cache HIT");
|
log.debug({ query, category, engines }, "SearXNG cache HIT");
|
||||||
return JSON.parse(cached) as SearxngResult[];
|
return JSON.parse(cached) as SearxngResult[];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,8 +117,11 @@ export async function searchSearxng(
|
|||||||
|
|
||||||
// Cache miss — hit SearXNG API
|
// Cache miss — hit SearXNG API
|
||||||
try {
|
try {
|
||||||
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}`;
|
const engineParam = engines
|
||||||
const { controller, clear } = createAbortControllerWithTimeout(TIMEOUT_MS);
|
? `&engines=${encodeURIComponent(engines)}`
|
||||||
|
: "";
|
||||||
|
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}${engineParam}`;
|
||||||
|
const { controller, clear } = createAbortControllerWithTimeout(timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
/**
|
||||||
|
* termGlossary.ts
|
||||||
|
*
|
||||||
|
* Per-word "kamus" enrichment for LLM moderation.
|
||||||
|
*
|
||||||
|
* Problem: the moderation LLM often meets words it does not know — regional
|
||||||
|
* slang (Jawa/Sunda), foreign terms, niche anime/game jargon, or obscure
|
||||||
|
* technical vocabulary. When it guesses, it either invents a wrong meaning
|
||||||
|
* (false positive on a safe word) or misses a violation hidden in unfamiliar
|
||||||
|
* wording (false negative on an unknown vulgar/slang term).
|
||||||
|
*
|
||||||
|
* Solution: extract candidate "unknown-looking" words from message content,
|
||||||
|
* look each one up on Wikipedia via SearXNG, and inject the definitions into
|
||||||
|
* the LLM prompt as a `<term_glossary>` block so verdicts are based on facts
|
||||||
|
* instead of guesses.
|
||||||
|
*
|
||||||
|
* Cost control & persistence:
|
||||||
|
* - successfully resolved definitions are PERSISTED PERMANENTLY in Postgres
|
||||||
|
* (`term_glossary_cache`) — definitions rarely change, so a resolved term
|
||||||
|
* is never searched again; only misses stay ephemeral (Redis/LRU, 1h);
|
||||||
|
* - in-memory LRU + Redis (shared with the SearXNG cache) sit in front of
|
||||||
|
* the DB as fast read caches, so repeat lookups are effectively free;
|
||||||
|
* - lookups per batch are bounded (AI_GLOSSARY_MAX_TERMS);
|
||||||
|
* - live SearXNG calls are rate-limit aware: concurrency 2 + stagger, retry
|
||||||
|
* once on empty results, and misses cached for only 1h so a limiter/
|
||||||
|
* network blip is not treated as a permanent miss;
|
||||||
|
* - only results that read like actual definitions are accepted (Wikipedia
|
||||||
|
* preferred; disambiguation/ads/translate-homepages rejected);
|
||||||
|
* - everything degrades gracefully: no Redis, no SearXNG, no match
|
||||||
|
* → the block is simply omitted and moderation proceeds as before.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
|
import pLimit from "p-limit";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { delay } from "@/shared/utils/index";
|
||||||
|
import { config } from "../../shared/config/config.js";
|
||||||
|
import { escapeXml } from "./moderationBuilders.js";
|
||||||
|
import {
|
||||||
|
makeSearxngCacheKey,
|
||||||
|
searchSearxng,
|
||||||
|
searxngCacheGet,
|
||||||
|
searxngCacheSet,
|
||||||
|
} from "./searxngSearch.js";
|
||||||
|
import {
|
||||||
|
getTermDefinitionFromDb,
|
||||||
|
setTermDefinitionInDb,
|
||||||
|
} from "./termGlossaryStore.js";
|
||||||
|
|
||||||
|
const log = createChildLogger("term-glossary");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Redis TTL for a successfully resolved definition (definitions are stable). */
|
||||||
|
const DEF_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||||
|
/**
|
||||||
|
* Redis TTL for a lookup that found nothing. Kept SHORT (1h): SearXNG
|
||||||
|
* instances silently return empty result sets when rate-limited, so an empty
|
||||||
|
* response is often a transient failure, not a real miss. A short TTL lets
|
||||||
|
* the term be retried on a later batch instead of poisoning it for a day.
|
||||||
|
*/
|
||||||
|
const MISS_TTL_SECONDS = 60 * 60;
|
||||||
|
const MISS_TTL_MS = MISS_TTL_SECONDS * 1000;
|
||||||
|
/** Sentinel stored in caches for "term has no resolvable definition". */
|
||||||
|
const EMPTY_SENTINEL = "__not_found__";
|
||||||
|
/** Per-search timeout — keep glossary lookups snappy even on a slow SearXNG. */
|
||||||
|
const GLOSSARY_SEARCH_TIMEOUT_MS = 5000;
|
||||||
|
/** Delay before retrying a search that returned zero results. */
|
||||||
|
const RETRY_DELAY_MS = 350;
|
||||||
|
/** Max definition snippet length kept in the prompt. */
|
||||||
|
const MAX_DEFINITION_CHARS = 300;
|
||||||
|
/**
|
||||||
|
* SearXNG rate-limits aggressive parallel bursts (returns 200 with empty
|
||||||
|
* results). Never fire all terms at once — cap live searches at 2 concurrent
|
||||||
|
* and stagger the start times slightly.
|
||||||
|
*/
|
||||||
|
const LIVE_SEARCH_CONCURRENCY = 2;
|
||||||
|
const LIVE_SEARCH_STAGGER_MS = 250;
|
||||||
|
|
||||||
|
/** In-memory cache: term (lowercase) → definition | NOT_FOUND sentinel. */
|
||||||
|
const NOT_FOUND: TermDefinition = {
|
||||||
|
term: "__not_found__",
|
||||||
|
definition: "",
|
||||||
|
sourceUrl: "",
|
||||||
|
};
|
||||||
|
const termLru = new LRUCache<string, TermDefinition>({
|
||||||
|
max: 2000,
|
||||||
|
ttl: 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Serializes live SearXNG lookups (rate-limit aware) with a small stagger. */
|
||||||
|
const liveSearchLimit = pLimit(LIVE_SEARCH_CONCURRENCY);
|
||||||
|
let lastLiveSearchAt = 0;
|
||||||
|
async function acquireLiveSlot(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const wait = lastLiveSearchAt + LIVE_SEARCH_STAGGER_MS - now;
|
||||||
|
if (wait > 0) await delay(wait);
|
||||||
|
lastLiveSearchAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Term extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Word tokenizer — letters/digits plus internal -_'· (handles "well-known",
|
||||||
|
* "node_modules", diacritics). */
|
||||||
|
const WORD_RE = /[\p{L}\p{N}]+(?:[-_'’·][\p{L}\p{N}]+)*/gu;
|
||||||
|
|
||||||
|
/** Removes URLs, Discord mentions/custom emoji, code fences, markdown noise. */
|
||||||
|
function cleanContent(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/https?:\/\/\S+/gi, " ")
|
||||||
|
.replace(/<@!?\d+>/g, " ")
|
||||||
|
.replace(/<#\d+>/g, " ")
|
||||||
|
.replace(/<a?:\w+:\d+>/g, " ")
|
||||||
|
.replace(/[`*_~|>[\]]/g, " ")
|
||||||
|
.replace(/[\p{Emoji}\p{Extended_Pictographic}]/gu, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters out tokens that are useless as glossary candidates (numbers,
|
||||||
|
* repeated-char noise, mega-tokens). */
|
||||||
|
function isNoiseWord(word: string): boolean {
|
||||||
|
if (word.length > 28) return true;
|
||||||
|
if (/^\d+$/.test(word)) return true;
|
||||||
|
const lower = word.toLowerCase();
|
||||||
|
// "aaaa…", "wwwwww" — single repeated character
|
||||||
|
if (/^(.)\1{2,}$/.test(lower)) return true;
|
||||||
|
// "wkwk", "hehe", "69" alternations — repeated 2–3 char base. "meme" is
|
||||||
|
// the one legit 4-letter word this matches; it is whitelisted below.
|
||||||
|
if (/^([a-z]{2,3})\1{1,}$/.test(lower)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic bonus for words that look like proper nouns or foreign. */
|
||||||
|
function scoreWord(word: string): number {
|
||||||
|
let score = 1;
|
||||||
|
// Capitalized first letter (proper noun / title) but not ALL-CAPS acronyms
|
||||||
|
if (/^[A-Z]/.test(word) && !/^[A-Z]{2,}$/.test(word)) score += 3;
|
||||||
|
// Contains a letter outside basic latin → regional/foreign spelling
|
||||||
|
if (/[\p{L}]/u.test(word.replace(/[A-Za-z]/g, ""))) score += 2;
|
||||||
|
// Contains an internal apostrophe or hyphen → likely a named entity
|
||||||
|
if (/[-_'’·]/.test(word)) score += 2;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPWORDS = new Set(
|
||||||
|
// ── Bahasa Indonesia ────────────────────────────────────────────────
|
||||||
|
(
|
||||||
|
" yang dan di ke dari ini itu dengan untuk pada dalam adalah akan telah sudah bisa dapat harus tidak juga saya kamu kita kami mereka dia aku kau gua lu lo gw gue elu anda kalian nya kah lah pun ya yah kan sih dong deh kok loh toh aja saja gitu gini begitu begini tapi tetapi namun atau karena sebab jika kalau bila maka supaya agar meski meskipun walau walaupun ketika saat setelah sebelum selama antara terhadap tentang mengenai bagi oleh secara sebagai seperti daripada tanpa hingga sampai sejak menuju bahwa padahal sebenarnya sepertinya mungkin memang jadi lalu terus akhirnya misalnya contohnya banyak sedikit semua seluruh setiap tiap beberapa ada bukan jangan boleh mau ingin pengen nggak ngak gak ga kagak ngga ndak nanti kemarin besok hari ini sekarang waktu itu masih sedang belum pernah sering selalu kadang jarang cepat lambat awal akhir baru lama besar kecil tinggi rendah panjang pendek baik buruk benar salah sama beda penting biasanya selamat terima kasih makasih sangat sekali paling cuma cuman hanya lebih kurang sekitar hampir ternyata rupanya begitu gimana bagaimana kenapa mengapa siapa apa mana kapan darimana kemana bilang ngomong omong kata tadi dulu terus lagi tetap pasti seharusnya sebaiknya seakan seolah kayaknya keliatan kelihatan ketahuan disini disitu disana kesini kesana bener pake pakai kayak emang lagian mulu istilah istilahnya banget" +
|
||||||
|
// ── English ───────────────────────────────────────────────────────
|
||||||
|
" the a an and or but if then else for to in on at by with without from of is are was were be been being have has had do does did will would can could should may might must shall this that these those it its i you he she we they them their there here when where why how what which who whom whose only very just about above after before below under over into onto within upon against between among during through across along around behind beyond near off out up down now then so as not no yes ok okay" +
|
||||||
|
// ── Common net slang / acronyms the LLM already knows ──────────────
|
||||||
|
" lol omg wtf idk btw tbh imo aka fyi nsfw smh nvm asap afk brb gg wp ty np mb sry thx kk oke okk ygy frfr"
|
||||||
|
).split(/\s+/),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words that are either already defined by the moderation rules, or are so
|
||||||
|
* common (brands, tech vocabulary, project names) that a Wikipedia lookup is
|
||||||
|
* a guaranteed miss/waste. Keeps the glossary focused on genuinely unknown
|
||||||
|
* terms.
|
||||||
|
*/
|
||||||
|
const KNOWN_SAFE_TERMS = new Set(
|
||||||
|
(
|
||||||
|
"discord youtube google facebook instagram twitter tiktok whatsapp telegram netflix spotify steam github gitlab bitbucket chatgpt openai anthropic claude deepseek gemini llama copilot cursor vscode vscodium jetbrains intellij pycharm webstorm sublime codeblocks" +
|
||||||
|
" docker kubernetes k8s linux ubuntu debian arch fedora manjaro kali windows macos android ios chrome firefox safari edge opera brave" +
|
||||||
|
" react nextjs next vue svelte angular node nodejs deno bun pnpm yarn npm javascript typescript python golang go rust java kotlin swift cplusplus cpp css html json xml yaml toml regex backend frontend database mysql postgres postgresql mongodb redis qdrant sqlite nosql graphql rest websocket webhook" +
|
||||||
|
" bug crash error debug fix issue pr merge commit push pull branch main master dev staging production server client app website web browser" +
|
||||||
|
" stream streaming video audio voice call camera screen share screenshare gameplay gaming game play steam epic xbox playstation nintendo switch console" +
|
||||||
|
" bot discordbot moderation moderator admin member user profile avatar channel server guild message chat dm reply forward embed sticker emoji role permission" +
|
||||||
|
" meme code coding ngoding programmer program developer engineer software hardware cpu gpu ram rom storage disk network internet wifi lan ip dns vpn proxy cloud aws azure gcp vercel netlify heroku railway render vps hosting domain ssl login logout register account password email username" +
|
||||||
|
" anime manga waifu husbando tsundere moe otaku wibu weeb otome isekai shonen seinen josei manga manhwa manhua doujin" +
|
||||||
|
" anjay wkwk wkwkwk gws gaskeun santuy njir baka woy woi hadeh astaga asu anjing bangsat ngehe asal alay lebay caper mabar" +
|
||||||
|
" asus bete imphnen impnhen ngab" +
|
||||||
|
" syahadat sholat shalat solat puasa zakat haji umrah doa tuhan nabi allah yesus muhammad hashem" +
|
||||||
|
" loli shota incest exhibition furry fursuit cosplay costume" +
|
||||||
|
" gaza palestine israel yahudi yahud israel palestina israeli" +
|
||||||
|
" hokkian mandarin arabic jawa sunda betawi minang bugis batak melayu inggris indonesia"
|
||||||
|
).split(/\s+/),
|
||||||
|
);
|
||||||
|
|
||||||
|
function isKnownTerm(word: string): boolean {
|
||||||
|
return STOPWORDS.has(word) || KNOWN_SAFE_TERMS.has(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a quoted phrase is mostly filler words (skip it). */
|
||||||
|
function isMostlyStopwords(phrase: string): boolean {
|
||||||
|
const words = phrase
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-zà-öø-ÿ]+/i)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (words.length === 0) return true;
|
||||||
|
const stopCount = words.filter((w) => STOPWORDS.has(w)).length;
|
||||||
|
return stopCount / words.length >= 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractGlossaryOptions {
|
||||||
|
maxTerms?: number;
|
||||||
|
minWordLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts candidate terms that the LLM might not know from message content.
|
||||||
|
* Returns at most `maxTerms` terms (default from config), scored by how
|
||||||
|
* "unknown-looking" they are (proper nouns, foreign spelling, quoted phrases).
|
||||||
|
*/
|
||||||
|
export function extractGlossaryTerms(
|
||||||
|
contents: string[],
|
||||||
|
options: ExtractGlossaryOptions = {},
|
||||||
|
): string[] {
|
||||||
|
const maxTerms = options.maxTerms ?? config.AI_GLOSSARY_MAX_TERMS;
|
||||||
|
const minWordLength =
|
||||||
|
options.minWordLength ?? config.AI_GLOSSARY_MIN_WORD_LENGTH;
|
||||||
|
|
||||||
|
const candidates = new Map<string, { word: string; score: number }>();
|
||||||
|
|
||||||
|
const push = (rawWord: string, score: number): void => {
|
||||||
|
const clean = rawWord
|
||||||
|
.trim()
|
||||||
|
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
||||||
|
if (clean.length < minWordLength) return;
|
||||||
|
const key = clean.toLowerCase();
|
||||||
|
if (isKnownTerm(key) || isNoiseWord(clean)) return;
|
||||||
|
const existing = candidates.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.score += score + 1;
|
||||||
|
} else {
|
||||||
|
candidates.set(key, { word: clean, score });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const content of contents) {
|
||||||
|
if (!content) continue;
|
||||||
|
const cleaned = cleanContent(content);
|
||||||
|
if (!cleaned) continue;
|
||||||
|
|
||||||
|
// Quoted phrases — explicit terms the user called out
|
||||||
|
for (const m of cleaned.matchAll(/"([^"]{2,80})"/g)) {
|
||||||
|
const phrase = m[1].trim();
|
||||||
|
const wordCount = phrase.split(/\s+/).length;
|
||||||
|
if (wordCount >= 2 && wordCount <= 6 && !isMostlyStopwords(phrase)) {
|
||||||
|
push(phrase, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual words
|
||||||
|
for (const m of cleaned.matchAll(WORD_RE)) {
|
||||||
|
const w = m[0];
|
||||||
|
if (w.length < minWordLength) continue;
|
||||||
|
if (isNoiseWord(w)) continue;
|
||||||
|
const key = w.toLowerCase();
|
||||||
|
if (isKnownTerm(key)) continue;
|
||||||
|
push(w, scoreWord(w));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(candidates.values())
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, maxTerms)
|
||||||
|
.map((c) => c.word);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Definition lookup (cached: LRU → Redis → SearXNG/Wikipedia)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TermDefinition {
|
||||||
|
term: string;
|
||||||
|
definition: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Definition-like markers for accepting a non-Wikipedia search result. */
|
||||||
|
const DEF_MARKERS =
|
||||||
|
/adalah|merupakan|istilah (?:untuk|yang|yg)|artinya|sebutan|berarti|refers? to|known as|also called|short for|a term (?:for|used)|istilah dalam|kata (?:asing|serapan)? ?untuk/i;
|
||||||
|
|
||||||
|
/** True when the term appears in the result text (or a 4+ char word in the
|
||||||
|
* result is part of the term). Lenient — "kafircel" matches a "Kafir"
|
||||||
|
* article via substring, while a Google-Translate homepage snippet does not. */
|
||||||
|
function hasTermOverlap(term: string, title: string, snippet: string): boolean {
|
||||||
|
const termLower = term.toLowerCase();
|
||||||
|
const text = `${title} ${snippet}`.toLowerCase();
|
||||||
|
if (text.includes(termLower)) return true;
|
||||||
|
const words = text.match(/[a-z0-9]{4,}/gi) ?? [];
|
||||||
|
return words.some((w) => termLower.includes(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quality gate: is this result good enough to quote as a definition? */
|
||||||
|
function isUsableDefinition(
|
||||||
|
r: { title: string; url: string; snippet: string },
|
||||||
|
term: string,
|
||||||
|
isWiki: boolean,
|
||||||
|
): boolean {
|
||||||
|
const text = `${r.title} ${r.snippet}`;
|
||||||
|
// Wikipedia disambiguation pages are not definitions
|
||||||
|
if (/disambiguasi|disambiguation/i.test(text)) return false;
|
||||||
|
if ((r.snippet ?? "").trim().length < 25) return false;
|
||||||
|
if (!hasTermOverlap(term, r.title, r.snippet)) return false;
|
||||||
|
// Wikipedia articles are accepted with just the overlap+length gate;
|
||||||
|
// everything else must read like an actual definition, not an ad,
|
||||||
|
// a translate homepage, or a navigation blurb.
|
||||||
|
if (isWiki) return true;
|
||||||
|
return DEF_MARKERS.test(r.snippet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the best definition from search results, preferring a genuine
|
||||||
|
* Wikipedia article; otherwise the first result that reads like a
|
||||||
|
* definition. Returns null when nothing qualifies. */
|
||||||
|
function pickDefinition(
|
||||||
|
results: Array<{ title: string; url: string; snippet: string }>,
|
||||||
|
term: string,
|
||||||
|
): TermDefinition | null {
|
||||||
|
const wiki = results.find((r) => /wikipedia\.org/i.test(r.url));
|
||||||
|
const best = wiki && isUsableDefinition(wiki, term, true) ? wiki : null;
|
||||||
|
if (!best) {
|
||||||
|
for (const r of results) {
|
||||||
|
if (isUsableDefinition(r, term, false)) {
|
||||||
|
return buildDefinition(r, term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildDefinition(best, term);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefinition(
|
||||||
|
best: { title: string; url: string; snippet: string },
|
||||||
|
term: string,
|
||||||
|
): TermDefinition {
|
||||||
|
const snippet = (best.snippet || best.title || "").trim();
|
||||||
|
const definition =
|
||||||
|
snippet.length > MAX_DEFINITION_CHARS
|
||||||
|
? `${snippet.slice(0, MAX_DEFINITION_CHARS - 1).trimEnd()}…`
|
||||||
|
: snippet;
|
||||||
|
return { term, definition, sourceUrl: best.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live (network) lookup — runs under the shared SearXNG rate-limit gate. */
|
||||||
|
async function fetchDefinitionLive(
|
||||||
|
term: string,
|
||||||
|
key: string,
|
||||||
|
cacheKey: string,
|
||||||
|
): Promise<TermDefinition | null> {
|
||||||
|
return liveSearchLimit(async () => {
|
||||||
|
await acquireLiveSlot();
|
||||||
|
try {
|
||||||
|
let results = await searchSearxng(
|
||||||
|
key,
|
||||||
|
"general",
|
||||||
|
undefined,
|
||||||
|
GLOSSARY_SEARCH_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
let def = pickDefinition(results, term);
|
||||||
|
// Zero results is usually the limiter kicking in, not a real miss —
|
||||||
|
// retry once. Results-but-unusable = genuine miss, no retry.
|
||||||
|
if (!def && results.length === 0) {
|
||||||
|
await delay(RETRY_DELAY_MS);
|
||||||
|
results = await searchSearxng(
|
||||||
|
key,
|
||||||
|
"general",
|
||||||
|
undefined,
|
||||||
|
GLOSSARY_SEARCH_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
def = pickDefinition(results, term);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def) {
|
||||||
|
// Persist permanently (definitions rarely change) — best-effort,
|
||||||
|
// then warm the fast caches.
|
||||||
|
void setTermDefinitionInDb(key, def.definition, def.sourceUrl);
|
||||||
|
searxngCacheSet(
|
||||||
|
cacheKey,
|
||||||
|
JSON.stringify({
|
||||||
|
definition: def.definition,
|
||||||
|
sourceUrl: def.sourceUrl,
|
||||||
|
}),
|
||||||
|
DEF_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
termLru.set(key, def);
|
||||||
|
log.debug({ term: key }, "Term glossary resolved definition");
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.debug(
|
||||||
|
{ term: key, error: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Term glossary lookup failed — skipping term",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No definition — cache the miss with a SHORT TTL so a transient
|
||||||
|
// limiter/network failure is retried on a later batch.
|
||||||
|
searxngCacheSet(cacheKey, EMPTY_SENTINEL, MISS_TTL_SECONDS);
|
||||||
|
termLru.set(key, NOT_FOUND, { ttl: MISS_TTL_MS });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve one term: LRU → Redis → Postgres (permanent) → live SearXNG
|
||||||
|
* (rate-limited). The fast caches sit in front of the DB; the DB is the
|
||||||
|
* source of truth for successfully resolved definitions. */
|
||||||
|
async function resolveTerm(term: string): Promise<TermDefinition | null> {
|
||||||
|
const key = term.toLowerCase().trim();
|
||||||
|
|
||||||
|
// 1. In-memory LRU — same process, instant
|
||||||
|
const lruHit = termLru.get(key);
|
||||||
|
if (lruHit) return lruHit === NOT_FOUND ? null : lruHit;
|
||||||
|
|
||||||
|
// 2. Redis — shared across processes/workers. A miss sentinel here is NOT
|
||||||
|
// a definitive answer: it may predate a permanent DB entry written by
|
||||||
|
// another process, so we keep going and let the DB decide.
|
||||||
|
const cacheKey = makeSearxngCacheKey("def", key);
|
||||||
|
const cached = await searxngCacheGet(cacheKey);
|
||||||
|
let redisMiss = false;
|
||||||
|
if (cached !== null) {
|
||||||
|
if (cached === EMPTY_SENTINEL) {
|
||||||
|
redisMiss = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cached) as {
|
||||||
|
definition?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
};
|
||||||
|
if (parsed.definition) {
|
||||||
|
const def: TermDefinition = {
|
||||||
|
term,
|
||||||
|
definition: parsed.definition,
|
||||||
|
sourceUrl: parsed.sourceUrl ?? "",
|
||||||
|
};
|
||||||
|
termLru.set(key, def);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// malformed cache entry — fall through to DB/live
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Postgres — permanent store for resolved definitions. A hit re-warms
|
||||||
|
// the fast caches so the DB is not hit on every batch.
|
||||||
|
const dbDef = await getTermDefinitionFromDb(key);
|
||||||
|
if (dbDef) {
|
||||||
|
const def: TermDefinition = {
|
||||||
|
term,
|
||||||
|
definition: dbDef.definition,
|
||||||
|
sourceUrl: dbDef.sourceUrl,
|
||||||
|
};
|
||||||
|
termLru.set(key, def);
|
||||||
|
searxngCacheSet(
|
||||||
|
cacheKey,
|
||||||
|
JSON.stringify({ definition: def.definition, sourceUrl: def.sourceUrl }),
|
||||||
|
DEF_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
log.debug({ term: key }, "Term glossary DB hit");
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Redis already said "miss" recently and the DB has nothing — respect
|
||||||
|
// that instead of hammering SearXNG again within the miss window.
|
||||||
|
if (redisMiss) {
|
||||||
|
termLru.set(key, NOT_FOUND, { ttl: MISS_TTL_MS });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Live search (rate-limited + staggered)
|
||||||
|
return fetchDefinitionLive(term, key, cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up definitions for a batch of terms, in parallel. Returns a map of
|
||||||
|
* term → definition for the terms that resolved. Errors/misses are skipped.
|
||||||
|
* Live SearXNG calls are throttled internally (concurrency 2 + stagger).
|
||||||
|
*/
|
||||||
|
export async function lookupTermDefinitions(
|
||||||
|
terms: string[],
|
||||||
|
): Promise<Map<string, TermDefinition>> {
|
||||||
|
const map = new Map<string, TermDefinition>();
|
||||||
|
if (terms.length === 0) return map;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(terms.map(resolveTerm));
|
||||||
|
for (let i = 0; i < terms.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
if (r.status === "fulfilled" && r.value) {
|
||||||
|
map.set(r.value.term, r.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats definitions as a `<term_glossary>` XML block for the LLM prompt:
|
||||||
|
*
|
||||||
|
* <term_glossary>
|
||||||
|
* <term word="ngab" source="https://…">definisi…</term>
|
||||||
|
* </term_glossary>
|
||||||
|
*
|
||||||
|
* Returns "" when there are no definitions (the block is then omitted).
|
||||||
|
*/
|
||||||
|
export function formatTermGlossary(
|
||||||
|
defs: ReadonlyMap<string, TermDefinition>,
|
||||||
|
): string {
|
||||||
|
if (!defs || defs.size === 0) return "";
|
||||||
|
const lines = Array.from(defs.values()).map(
|
||||||
|
(d) =>
|
||||||
|
` <term word="${escapeXml(d.term)}" source="${escapeXml(d.sourceUrl)}">${escapeXml(d.definition)}</term>`,
|
||||||
|
);
|
||||||
|
return `<term_glossary>\n${lines.join("\n")}\n</term_glossary>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience: full pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GlossaryBlockOptions extends ExtractGlossaryOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot helper: extract terms from message contents, look up definitions,
|
||||||
|
* and return the formatted `<term_glossary>` block ("" when disabled or no
|
||||||
|
* definitions found). Safe to call on every batch — cached lookups make it
|
||||||
|
* cheap.
|
||||||
|
*/
|
||||||
|
export async function buildTermGlossaryBlock(
|
||||||
|
contents: string[],
|
||||||
|
options: GlossaryBlockOptions = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const enabled = options.enabled ?? config.AI_GLOSSARY_ENABLED;
|
||||||
|
if (!enabled) return "";
|
||||||
|
if (contents.length === 0) return "";
|
||||||
|
|
||||||
|
const terms = extractGlossaryTerms(contents, options);
|
||||||
|
if (terms.length === 0) return "";
|
||||||
|
|
||||||
|
const defs = await lookupTermDefinitions(terms);
|
||||||
|
if (defs.size === 0) return "";
|
||||||
|
|
||||||
|
const block = formatTermGlossary(defs);
|
||||||
|
log.debug(
|
||||||
|
{ terms: terms.length, definitions: defs.size },
|
||||||
|
"Term glossary block built",
|
||||||
|
);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* termGlossaryStore.ts
|
||||||
|
*
|
||||||
|
* Permanent Postgres layer for the term glossary. Resolved definitions
|
||||||
|
* (which carry content) are persisted here because they rarely change —
|
||||||
|
* Redis/LRU only act as fast read caches in front of this table. Terms with
|
||||||
|
* no definition (misses) are deliberately NOT persisted; they stay ephemeral
|
||||||
|
* in Redis with a short TTL so transient lookup failures get retried.
|
||||||
|
*
|
||||||
|
* All calls are best-effort: any DB error degrades to a cache miss (the
|
||||||
|
* glossary then falls through to Redis/live search as if the DB layer
|
||||||
|
* didn't exist).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
|
||||||
|
|
||||||
|
const log = createChildLogger("term-glossary-store");
|
||||||
|
|
||||||
|
export interface StoredTermDefinition {
|
||||||
|
definition: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a permanently stored definition for a term (lowercase key).
|
||||||
|
* Returns null when missing or on any DB error (callers fall through).
|
||||||
|
* A successful read bumps hit_count for observability (fire-and-forget).
|
||||||
|
*/
|
||||||
|
export async function getTermDefinitionFromDb(
|
||||||
|
term: string,
|
||||||
|
): Promise<StoredTermDefinition | null> {
|
||||||
|
try {
|
||||||
|
const row = await executeGet(
|
||||||
|
`SELECT definition, source_url FROM term_glossary_cache WHERE term = $1`,
|
||||||
|
[term.toLowerCase().trim()],
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
await executeAll(
|
||||||
|
`UPDATE term_glossary_cache SET hit_count = hit_count + 1 WHERE term = $1`,
|
||||||
|
[term.toLowerCase().trim()],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// hit_count is observability only — never fail a read for it
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
definition: row.definition as string,
|
||||||
|
sourceUrl: (row.source_url as string | null) ?? "",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log.debug(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"getTermDefinitionFromDb failed — falling back to live search",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a resolved definition permanently (UPSERT by term).
|
||||||
|
* Only called for successful resolutions — never for misses.
|
||||||
|
* Best-effort: a DB write failure does not affect the returned definition.
|
||||||
|
*/
|
||||||
|
export async function setTermDefinitionInDb(
|
||||||
|
term: string,
|
||||||
|
definition: string,
|
||||||
|
sourceUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await executeAll(
|
||||||
|
`INSERT INTO term_glossary_cache (term, definition, source_url, resolved_at, hit_count)
|
||||||
|
VALUES ($1, $2, $3, $4, 0)
|
||||||
|
ON CONFLICT (term) DO UPDATE SET
|
||||||
|
definition = EXCLUDED.definition,
|
||||||
|
source_url = EXCLUDED.source_url,
|
||||||
|
resolved_at = EXCLUDED.resolved_at`,
|
||||||
|
[term.toLowerCase().trim(), definition, sourceUrl, Date.now()],
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"setTermDefinitionInDb failed — definition stays memory/Redis only",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ import { callModerationLLM } from "./llmCaller.js";
|
|||||||
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
|
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
|
||||||
import {
|
import {
|
||||||
buildReferenceXml,
|
buildReferenceXml,
|
||||||
buildUserHistoryXml,
|
|
||||||
buildUserProfileRef,
|
buildUserProfileRef,
|
||||||
buildUserProfilesBlock,
|
buildUserProfilesBlock,
|
||||||
escapeXml,
|
escapeXml,
|
||||||
@@ -37,13 +36,11 @@ import {
|
|||||||
formatSearchResults,
|
formatSearchResults,
|
||||||
searchSearxng,
|
searchSearxng,
|
||||||
} from "./searxngSearch.js";
|
} from "./searxngSearch.js";
|
||||||
|
import { buildTermGlossaryBlock } from "./termGlossary.js";
|
||||||
import { getRecentCorrectedModerations } from "./textCacheStore.js";
|
import { getRecentCorrectedModerations } from "./textCacheStore.js";
|
||||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||||
import { getUserProfile } from "./userProfileStore.js";
|
import { getUserProfile } from "./userProfileStore.js";
|
||||||
import {
|
import { initializeUserReputation } from "./userReputationStore.js";
|
||||||
getUserRecentInfractions,
|
|
||||||
initializeUserReputation,
|
|
||||||
} from "./userReputationStore.js";
|
|
||||||
import type { MessageImagePart } from "./visionAnalyzer.js";
|
import type { MessageImagePart } from "./visionAnalyzer.js";
|
||||||
|
|
||||||
const log = createChildLogger("textBatchProcessor");
|
const log = createChildLogger("textBatchProcessor");
|
||||||
@@ -145,9 +142,17 @@ export async function runTextOnlyBatch(
|
|||||||
return map;
|
return map;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const [urlFetchMaps, searxngResults] = await Promise.all([
|
// Term glossary — per-word Wikipedia lookups for words the LLM may not
|
||||||
|
// know (slang, jargon, regional language). Cached in Redis + in-memory, so
|
||||||
|
// repeat terms resolve instantly and only genuinely new words hit SearXNG.
|
||||||
|
const glossaryPromise = buildTermGlossaryBlock(
|
||||||
|
targets.map((msg) => getAnalysisContent(msg)),
|
||||||
|
).catch(() => "");
|
||||||
|
|
||||||
|
const [urlFetchMaps, searxngResults, glossaryBlock] = await Promise.all([
|
||||||
urlFetchPromise,
|
urlFetchPromise,
|
||||||
searxngPromise,
|
searxngPromise,
|
||||||
|
glossaryPromise,
|
||||||
]);
|
]);
|
||||||
const urlFetchMap = urlFetchMaps.text;
|
const urlFetchMap = urlFetchMaps.text;
|
||||||
|
|
||||||
@@ -209,27 +214,7 @@ export async function runTextOnlyBatch(
|
|||||||
if (!userContexts.has(msg.user_id)) {
|
if (!userContexts.has(msg.user_id)) {
|
||||||
const rep = await initializeUserReputation(msg.user_id, msg.guild_id);
|
const rep = await initializeUserReputation(msg.user_id, msg.guild_id);
|
||||||
const repAttrs = formatReputationAttrs(rep);
|
const repAttrs = formatReputationAttrs(rep);
|
||||||
let repXml = `<user_reputation ${repAttrs}/>`;
|
const repXml = `<user_reputation ${repAttrs}/>`;
|
||||||
// Repeat offenders get their last flagged messages as <user_history>
|
|
||||||
// so the LLM can recognize PATTERNS (same scam link, repeated
|
|
||||||
// provocation) — history is reference, never proof. Best-effort.
|
|
||||||
if (rep.total_infractions > 0) {
|
|
||||||
try {
|
|
||||||
const history = await getUserRecentInfractions(msg.user_id, 2);
|
|
||||||
const historyXml = buildUserHistoryXml(
|
|
||||||
history.map((h) => ({
|
|
||||||
content: h.content ?? "",
|
|
||||||
severity: h.severity,
|
|
||||||
created_at: h.created_at,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
if (historyXml) {
|
|
||||||
repXml = `<user_reputation ${repAttrs}>\n${historyXml}\n</user_reputation>`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// history is a bonus — fall back to attrs-only reputation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userContexts.set(msg.user_id, repXml);
|
userContexts.set(msg.user_id, repXml);
|
||||||
}
|
}
|
||||||
if (!userProfiles.has(msg.user_id)) {
|
if (!userProfiles.has(msg.user_id)) {
|
||||||
@@ -368,6 +353,7 @@ export async function runTextOnlyBatch(
|
|||||||
userProfilesBlock?.trimEnd() ?? "",
|
userProfilesBlock?.trimEnd() ?? "",
|
||||||
contextBlock?.trimEnd() ?? "",
|
contextBlock?.trimEnd() ?? "",
|
||||||
searxngBlock,
|
searxngBlock,
|
||||||
|
glossaryBlock,
|
||||||
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
||||||
].filter((b) => b.trim().length > 0);
|
].filter((b) => b.trim().length > 0);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -62,14 +62,29 @@ export function makeCustomEmojiCacheKey(emojiId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a deterministic cache key for an image data URL.
|
* Generate a deterministic cache key for an image from its source URL
|
||||||
* Hashes the first 128 chars of the data URL (enough to identify the image
|
* (Discord CDN / embed URL / inline URL).
|
||||||
* without storing the full base64 string as the key).
|
*
|
||||||
|
* The CDN URL is the stable identity of an attachment: re-analysis of the
|
||||||
|
* same message (recovery worker, retries) always hits the cache regardless
|
||||||
|
* of resize/encoding output. Query params are stripped (Discord signed
|
||||||
|
* tokens `?ex=&is=&hm=` and render variants `?format=&width=`) so the same
|
||||||
|
* attachment resolves to the same key even when fetched with different
|
||||||
|
* signatures or sizes.
|
||||||
|
*
|
||||||
|
* No SHA/phash — the CDN URL is the cache key itself. This makes
|
||||||
|
* re-analysis of the SAME attachment cache-hit, while different attachments
|
||||||
|
* (different URLs) never collide.
|
||||||
*/
|
*/
|
||||||
export function makeImageCacheKey(dataUrl: string): string {
|
export function makeImageCacheKey(imageUrl: string): string {
|
||||||
const prefix = dataUrl.slice(0, 128);
|
try {
|
||||||
const hash = createHash("sha256").update(prefix).digest("hex").slice(0, 16);
|
const u = new URL(imageUrl);
|
||||||
return `image:${hash}`;
|
u.search = "";
|
||||||
|
u.hash = "";
|
||||||
|
return `image:${u.toString()}`;
|
||||||
|
} catch {
|
||||||
|
return `image:${imageUrl}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -515,64 +530,6 @@ export async function setCachedTextModeration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Perceptual hash helpers for image deduplication
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a deterministic cache key for a perceptual hash.
|
|
||||||
* The phash value is a string like "a1b2c3d4e5f6..." from the imghash library.
|
|
||||||
*/
|
|
||||||
export function makePhashCacheKey(phash: string): string {
|
|
||||||
return `phash:${phash.slice(0, 16)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look up a cached media analysis by perceptual hash.
|
|
||||||
* Returns the cached analysis string or null if not found/expired.
|
|
||||||
*/
|
|
||||||
export async function getCachedMediaByPhash(
|
|
||||||
phash: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const cacheKey = makePhashCacheKey(phash);
|
|
||||||
return getCachedMediaAnalysis(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a media analysis result keyed by perceptual hash.
|
|
||||||
*/
|
|
||||||
export async function upsertCachedMediaByPhash(
|
|
||||||
phash: string,
|
|
||||||
analysisResult: string,
|
|
||||||
source: "vision_llm",
|
|
||||||
expiresAt: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const cacheKey = makePhashCacheKey(phash);
|
|
||||||
return upsertCachedMediaAnalysis(cacheKey, analysisResult, source, expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute perceptual hash from image buffer using imghash.
|
|
||||||
* Returns a hexadecimal string representation of the hash.
|
|
||||||
* Returns null if hashing fails (e.g., invalid image data).
|
|
||||||
*/
|
|
||||||
export async function computeImagePhash(
|
|
||||||
buffer: Buffer,
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
// Dynamic import — imghash is ESM with a default export containing { hash, hashRaw, ... }
|
|
||||||
const imghashModule: {
|
|
||||||
default?: { hash?: (buf: Buffer) => Promise<string> };
|
|
||||||
} = await import("imghash");
|
|
||||||
const hashFn = imghashModule.default?.hash;
|
|
||||||
if (typeof hashFn !== "function") return null;
|
|
||||||
const hash = await hashFn(buffer);
|
|
||||||
return hash;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Corrected Moderation (false-positive) helpers for dynamic few-shot injection
|
// Corrected Moderation (false-positive) helpers for dynamic few-shot injection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,17 +16,14 @@ import type {
|
|||||||
import { llmVision } from "./llmClient.js";
|
import { llmVision } from "./llmClient.js";
|
||||||
import {
|
import {
|
||||||
acquireMediaAnalysisLock,
|
acquireMediaAnalysisLock,
|
||||||
computeImagePhash,
|
|
||||||
deleteCachedMediaAnalysis,
|
deleteCachedMediaAnalysis,
|
||||||
FAILED_ANALYSIS_PREFIX,
|
FAILED_ANALYSIS_PREFIX,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
getCachedMediaByPhash,
|
|
||||||
inFlightVisionCalls,
|
inFlightVisionCalls,
|
||||||
makeCustomEmojiCacheKey,
|
makeCustomEmojiCacheKey,
|
||||||
makeImageCacheKey,
|
makeImageCacheKey,
|
||||||
makeStickerCacheKey,
|
makeStickerCacheKey,
|
||||||
upsertCachedMediaAnalysis,
|
upsertCachedMediaAnalysis,
|
||||||
upsertCachedMediaByPhash,
|
|
||||||
visionLruCache,
|
visionLruCache,
|
||||||
} from "./mediaCache.js";
|
} from "./mediaCache.js";
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ import {
|
|||||||
} from "./mediaDownloader.js";
|
} from "./mediaDownloader.js";
|
||||||
import {
|
import {
|
||||||
buildReferenceXml,
|
buildReferenceXml,
|
||||||
buildUserHistoryXml,
|
|
||||||
buildUserProfileRef,
|
buildUserProfileRef,
|
||||||
escapeXml,
|
escapeXml,
|
||||||
formatReputationAttrs,
|
formatReputationAttrs,
|
||||||
@@ -87,12 +83,10 @@ import {
|
|||||||
formatSearchResults,
|
formatSearchResults,
|
||||||
searchSearxng,
|
searchSearxng,
|
||||||
} from "./searxngSearch.js";
|
} from "./searxngSearch.js";
|
||||||
|
import { buildTermGlossaryBlock } from "./termGlossary.js";
|
||||||
import { extractUrlsFromText } from "./urlFetcher.js";
|
import { extractUrlsFromText } from "./urlFetcher.js";
|
||||||
import { getUserProfile } from "./userProfileStore.js";
|
import { getUserProfile } from "./userProfileStore.js";
|
||||||
import {
|
import { initializeUserReputation } from "./userReputationStore.js";
|
||||||
getUserRecentInfractions,
|
|
||||||
initializeUserReputation,
|
|
||||||
} from "./userReputationStore.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -162,7 +156,10 @@ export const analyzeSingleMediaImage = async (
|
|||||||
const cached = await getCachedMediaAnalysis(cacheKey);
|
const cached = await getCachedMediaAnalysis(cacheKey);
|
||||||
if (cached && !isNoImageSeenText(cached)) {
|
if (cached && !isNoImageSeenText(cached)) {
|
||||||
visionLruCache.set(cacheKey, cached);
|
visionLruCache.set(cacheKey, cached);
|
||||||
log.debug({ cacheKey }, "Media analysis cache HIT (DB → LRU)");
|
log.debug(
|
||||||
|
{ cacheKey, messageId, cachedLen: cached.length },
|
||||||
|
"Media analysis cache HIT (DB → LRU)",
|
||||||
|
);
|
||||||
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${cached}`;
|
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${cached}`;
|
||||||
}
|
}
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -206,45 +203,19 @@ export const analyzeSingleMediaImage = async (
|
|||||||
return FAILED_ANALYSIS_PREFIX;
|
return FAILED_ANALYSIS_PREFIX;
|
||||||
}
|
}
|
||||||
|
|
||||||
// phash check
|
|
||||||
let phash: string | null = null;
|
|
||||||
if (image.image_url.url.startsWith("data:")) {
|
|
||||||
try {
|
|
||||||
const base64Data = image.image_url.url.split(",")[1];
|
|
||||||
if (base64Data) {
|
|
||||||
const imgBuffer = Buffer.from(base64Data, "base64");
|
|
||||||
phash = await computeImagePhash(imgBuffer);
|
|
||||||
if (phash) {
|
|
||||||
const phashCached = await getCachedMediaByPhash(phash);
|
|
||||||
if (phashCached && !isNoImageSeenText(phashCached)) {
|
|
||||||
visionLruCache.set(cacheKey, phashCached);
|
|
||||||
await upsertCachedMediaAnalysis(
|
|
||||||
cacheKey,
|
|
||||||
phashCached,
|
|
||||||
"vision_llm",
|
|
||||||
Date.now() + 24 * 60 * 60 * 1000,
|
|
||||||
).catch(() => {});
|
|
||||||
return phashCached;
|
|
||||||
}
|
|
||||||
if (phashCached) {
|
|
||||||
log.warn(
|
|
||||||
{ phash, cacheKey },
|
|
||||||
"phash cache HIT was no-image-seen — ignoring",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
phash = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vision API call
|
// Vision API call
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
const content = await llmVision(promptText, image.image_url);
|
const content = await llmVision(promptText, image.image_url);
|
||||||
if (content && !isNoImageSeenText(content)) {
|
if (content && !isNoImageSeenText(content)) {
|
||||||
|
// Defensive: log when a vision analysis is cached so we can trace
|
||||||
|
// if the SAME analysis text is being stored for DIFFERENT cache keys
|
||||||
|
// (which would indicate the vision model is returning duplicates).
|
||||||
|
log.debug(
|
||||||
|
{ cacheKey, messageId, contentLen: content.length },
|
||||||
|
"Vision analysis cached (new entry)",
|
||||||
|
);
|
||||||
await upsertCachedMediaAnalysis(
|
await upsertCachedMediaAnalysis(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
content,
|
content,
|
||||||
@@ -252,20 +223,11 @@ export const analyzeSingleMediaImage = async (
|
|||||||
Date.now() + 24 * 60 * 60 * 1000,
|
Date.now() + 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
visionLruCache.set(cacheKey, content);
|
visionLruCache.set(cacheKey, content);
|
||||||
if (phash) {
|
|
||||||
upsertCachedMediaByPhash(
|
|
||||||
phash,
|
|
||||||
content,
|
|
||||||
"vision_llm",
|
|
||||||
Date.now() + 7 * 24 * 60 * 60 * 1000,
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
if (content) {
|
if (content) {
|
||||||
// Model claims it saw no image — same as a null response: NOT a
|
// Model claims it saw no image — same as a null response: NOT a
|
||||||
// valid analysis, and caching it would poison the key for every
|
// valid analysis, and caching it would poison the cache key.
|
||||||
// re-analysis of the same image (phash TTL is 7 days).
|
|
||||||
log.warn(
|
log.warn(
|
||||||
{ messageId, cacheKey },
|
{ messageId, cacheKey },
|
||||||
"Vision returned no-image-seen text — not caching",
|
"Vision returned no-image-seen text — not caching",
|
||||||
@@ -413,6 +375,12 @@ export async function prepareMediaMessage(
|
|||||||
searxngXml = `\n<web_searches>\n${parts.join("\n")}\n</web_searches>`;
|
searxngXml = `\n<web_searches>\n${parts.join("\n")}\n</web_searches>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Term glossary — cached per-word Wikipedia definitions for words the LLM
|
||||||
|
// may not know. Bounded and cached (in-memory + Redis), so this adds no
|
||||||
|
// meaningful latency to the media path either.
|
||||||
|
const glossaryXml = await buildTermGlossaryBlock([content]).catch(() => "");
|
||||||
|
const glossaryCtx = glossaryXml ? `\n${glossaryXml}` : "";
|
||||||
|
|
||||||
// Build XML block
|
// Build XML block
|
||||||
const webTexts = webTextMap.get(targetId) ?? [];
|
const webTexts = webTextMap.get(targetId) ?? [];
|
||||||
const mediaAnalyses = mediaAnalysisMap.get(targetId) ?? [];
|
const mediaAnalyses = mediaAnalysisMap.get(targetId) ?? [];
|
||||||
@@ -442,30 +410,12 @@ export async function prepareMediaMessage(
|
|||||||
? buildUserProfileRef(target.user_id)
|
? buildUserProfileRef(target.user_id)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Rich reputation — same shape as the text path: attrs + optional
|
// Rich reputation — attrs only, no user history injection (per channel context preference)
|
||||||
// <user_history> with the last flagged messages for repeat offenders.
|
|
||||||
const repAttrs = formatReputationAttrs(rep);
|
const repAttrs = formatReputationAttrs(rep);
|
||||||
let repXml = `<user_reputation ${repAttrs}/>`;
|
const repXml = `<user_reputation ${repAttrs}/>`;
|
||||||
if (rep.total_infractions > 0) {
|
|
||||||
try {
|
|
||||||
const history = await getUserRecentInfractions(target.user_id, 2);
|
|
||||||
const historyXml = buildUserHistoryXml(
|
|
||||||
history.map((h) => ({
|
|
||||||
content: h.content ?? "",
|
|
||||||
severity: h.severity,
|
|
||||||
created_at: h.created_at,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
if (historyXml) {
|
|
||||||
repXml = `<user_reputation ${repAttrs}>\n${historyXml}\n</user_reputation>`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// history is a bonus — fall back to attrs-only reputation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBot = resolveIsBot(target);
|
const isBot = resolveIsBot(target);
|
||||||
const isEdited = resolveIsEdited(target);
|
const isEdited = resolveIsEdited(target);
|
||||||
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${repXml}${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
|
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${repXml}${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}${glossaryCtx}\n</message>`;
|
||||||
return { targetId, messageBlock };
|
return { targetId, messageBlock };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { PassThrough, type Readable } from "node:stream";
|
import { PassThrough, type Readable } from "node:stream";
|
||||||
import { StreamType } from "@discordjs/voice";
|
import { StreamType } from "@discordjs/voice";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
@@ -79,6 +88,22 @@ export function transcodeToHighQualityOgg(
|
|||||||
);
|
);
|
||||||
|
|
||||||
input.pipe(proc.stdin);
|
input.pipe(proc.stdin);
|
||||||
|
// ffmpeg teardown closes stdin while the upstream source may still write —
|
||||||
|
// swallow EPIPE / destroyed-stream errors so they don't crash the gateway.
|
||||||
|
proc.stdin.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (
|
||||||
|
err.code === "EPIPE" ||
|
||||||
|
err.code === "ERR_STREAM_DESTROYED" ||
|
||||||
|
err.code === "ERR_STREAM_WRITE_AFTER_END"
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
{ code: err.code },
|
||||||
|
"Transcode stdin closed during teardown",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error({ error: err.message }, "Transcode stdin error");
|
||||||
|
}
|
||||||
|
});
|
||||||
activeProcesses.add(proc);
|
activeProcesses.add(proc);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -198,6 +223,98 @@ function buildNotInstalledError(): Error {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the yt-dlp --cookies args. YouTube blocks anonymous embeds with a
|
||||||
|
* "Sign in to confirm you're not a bot" 403 unless yt-dlp is given a logged-
|
||||||
|
* in account's cookies. The path is configurable via GMW_YT_COOKIES_PATH
|
||||||
|
* (default: the BWS-provided file the deploy writes to /etc/.../ytcookies.txt).
|
||||||
|
* If the file doesn't exist we pass nothing and fall back to anon (YouTube
|
||||||
|
* may 403 — screen share will fail gracefully, not crash).
|
||||||
|
*/
|
||||||
|
var _cachedCookiePath: string | null = null;
|
||||||
|
function buildCookieArgs(): string[] {
|
||||||
|
// Single source of truth: BWS injects the account cookies via env
|
||||||
|
// (gmw_yt_downloader_cookies → GMW_YT_DOWNLOADER_COOKIES by bws-exec).
|
||||||
|
// We materialize them to a temp Netscape file because yt-dlp --cookies
|
||||||
|
// only accepts a file path, not stdin, and multiline env values are not
|
||||||
|
// reliable to pass directly on the spawn argv. Falls back to the on-disk
|
||||||
|
// file at GMW_YT_COOKIES_PATH (or /etc/gmw-discord-gateway/ytcookies.txt)
|
||||||
|
// which the Nix deploy writes from BWS once at start.
|
||||||
|
if (_cachedCookiePath) return ["--cookies", _cachedCookiePath];
|
||||||
|
const envCookies = process.env.GMW_YT_DOWNLOADER_COOKIES?.trim();
|
||||||
|
if (envCookies && envCookies.includes("LOGIN_INFO")) {
|
||||||
|
const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`);
|
||||||
|
writeFileSync(fdPath, envCookies);
|
||||||
|
try {
|
||||||
|
chmodSync(fdPath, 0o600);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
_cachedCookiePath = fdPath;
|
||||||
|
logger.info(
|
||||||
|
{ cookiePath: fdPath, source: "GMW_YT_DOWNLOADER_COOKIES env" },
|
||||||
|
"Using YouTube cookies (from BWS env)",
|
||||||
|
);
|
||||||
|
return ["--cookies", fdPath];
|
||||||
|
}
|
||||||
|
const cookiePath =
|
||||||
|
process.env.GMW_YT_COOKIES_PATH ?? "/etc/gmw-discord-gateway/ytcookies.txt";
|
||||||
|
try {
|
||||||
|
if (cookiePath && existsSync(cookiePath)) {
|
||||||
|
_cachedCookiePath = cookiePath;
|
||||||
|
logger.info(
|
||||||
|
{ cookiePath, source: "on-disk file" },
|
||||||
|
"Using YouTube cookies for yt-dlp",
|
||||||
|
);
|
||||||
|
return ["--cookies", cookiePath];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — fallback to anon */
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
"No YouTube cookies available; yt-dlp will use anonymous (YouTube may 403)",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invidious instances for anon YouTube fetch (fallback when cookies 403). */
|
||||||
|
export const INVIDIOUS_INSTANCES = [
|
||||||
|
"yewtu.be",
|
||||||
|
"yewtu.nanomorph.dev",
|
||||||
|
"invidious.snopyta.org",
|
||||||
|
"invidious.kavin.rocks",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** True if url is a YouTube watch URL (youtu.be / youtube.com/watch). */
|
||||||
|
export function isYoutubeWatchUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return (
|
||||||
|
u.hostname === "youtu.be" ||
|
||||||
|
(u.hostname === "www.youtube.com" && u.pathname === "/watch") ||
|
||||||
|
(u.hostname === "youtube.com" && u.pathname === "/watch")
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite a YouTube watch URL to an invidious instance (anon, no bot-check). */
|
||||||
|
export function toInvidiousUrl(url: string, instance: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname === "youtu.be") {
|
||||||
|
const id = u.pathname.slice(1);
|
||||||
|
return `https://${instance}/watch?v=${id}`;
|
||||||
|
}
|
||||||
|
const id = u.searchParams.get("v");
|
||||||
|
if (id) return `https://${instance}/watch?v=${id}`;
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -222,6 +339,7 @@ export function resolveMediaUrl(
|
|||||||
): Promise<MediaSourceResolution> {
|
): Promise<MediaSourceResolution> {
|
||||||
return new Promise<MediaSourceResolution>((resolve, reject) => {
|
return new Promise<MediaSourceResolution>((resolve, reject) => {
|
||||||
const format = options?.quality ?? "bestaudio";
|
const format = options?.quality ?? "bestaudio";
|
||||||
|
const cookieArgs = buildCookieArgs();
|
||||||
const args = [
|
const args = [
|
||||||
"-f",
|
"-f",
|
||||||
format,
|
format,
|
||||||
@@ -229,6 +347,7 @@ export function resolveMediaUrl(
|
|||||||
"-",
|
"-",
|
||||||
"--no-progress",
|
"--no-progress",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
|
...cookieArgs,
|
||||||
"--print",
|
"--print",
|
||||||
"before_dl:title",
|
"before_dl:title",
|
||||||
"--print",
|
"--print",
|
||||||
@@ -248,6 +367,20 @@ export function resolveMediaUrl(
|
|||||||
// `--print` headers to stderr — pipe stdout immediately so the child
|
// `--print` headers to stderr — pipe stdout immediately so the child
|
||||||
// never blocks on a full pipe while we wait for the headers on stderr.
|
// never blocks on a full pipe while we wait for the headers on stderr.
|
||||||
const mediaStream = new PassThrough();
|
const mediaStream = new PassThrough();
|
||||||
|
// Teardown (player stop / ffmpeg exit) destroys this stream while
|
||||||
|
// yt-dlp may still push bytes — without a listener an EPIPE /
|
||||||
|
// ERR_STREAM_DESTROYED surfaces as an uncaughtException.
|
||||||
|
mediaStream.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (
|
||||||
|
err.code === "EPIPE" ||
|
||||||
|
err.code === "ERR_STREAM_DESTROYED" ||
|
||||||
|
err.code === "ERR_STREAM_WRITE_AFTER_END"
|
||||||
|
) {
|
||||||
|
logger.debug({ code: err.code }, "Media stream closed during teardown");
|
||||||
|
} else {
|
||||||
|
logger.error({ error: err.message }, "Media stream error");
|
||||||
|
}
|
||||||
|
});
|
||||||
proc.stdout.pipe(mediaStream);
|
proc.stdout.pipe(mediaStream);
|
||||||
|
|
||||||
let stderrBuf = "";
|
let stderrBuf = "";
|
||||||
@@ -348,241 +481,99 @@ export function resolveMediaUrl(
|
|||||||
* Resolve a media URL to a single playable input stream for screen share /
|
* Resolve a media URL to a single playable input stream for screen share /
|
||||||
* GoLive streaming.
|
* GoLive streaming.
|
||||||
*
|
*
|
||||||
* yt-dlp `--get-url` with `bestvideo+bestaudio` prints the video-only and
|
* Streams the merged video+audio media directly from yt-dlp stdout (`-o -`).
|
||||||
* audio-only URLs on SEPARATE lines. The old code took only the first line
|
|
||||||
* (video-only) → ffmpeg had no audio track → GoLive stream had no sound.
|
|
||||||
*
|
*
|
||||||
* This returns a single input that `prepareStream` (which accepts only ONE
|
* This is deliberately NOT the old --dump-single-json + manual URL-fetch
|
||||||
* ffmpeg input) can consume while STILL including audio:
|
* approach: YouTube signs DASH URLs for the extracting client and rejects
|
||||||
* - If yt-dlp offers a merged progressive URL (one URL, video+audio) it is
|
* them with 403 when fetched raw by ffmpeg/curl (verified 2026-08-12: even
|
||||||
* returned directly.
|
* curl with the EXACT http_headers from the yt-dlp dump got 403 on some
|
||||||
* - Otherwise the video-only + audio-only DASH URLs are fetched in the SAME
|
* videos, while yt-dlp's own downloader succeeded). Streaming from yt-dlp
|
||||||
* yt-dlp run (signature URLs expire quickly) and merged locally by an
|
* lets it handle auth, cookies and transient retries internally — the same
|
||||||
* ffmpeg process into a single NUT stream, which is streamed to the
|
* mechanism resolveMediaUrl already uses for music playback.
|
||||||
* consumer over a Readable. NUT over stdin auto-probes cleanly (verified:
|
|
||||||
* av1+opus merge → H264+opus transcode).
|
|
||||||
*
|
*
|
||||||
* @returns a direct video URL (string) or a Readable of the merged NUT stream.
|
* @returns a Readable of the merged media stream.
|
||||||
*/
|
*/
|
||||||
export function getDirectScreenInput(url: string): Promise<string | Readable> {
|
export function getDirectScreenInput(url: string): Promise<Readable> {
|
||||||
return new Promise<string | Readable>((resolve, reject) => {
|
return new Promise<Readable>((resolve) => {
|
||||||
|
// Merge fragments must NOT be written to the process CWD — the Nix
|
||||||
|
// store dir is read-only for the deployed gateway (EACCES). Use a
|
||||||
|
// per-run temp dir (world-writable like /tmp) so parallel/retry runs
|
||||||
|
// never collide on merge fragments and any user can write to it.
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "gmw-ytdlp-"));
|
||||||
|
chmodSync(tmpDir, 0o1777);
|
||||||
|
|
||||||
|
const cookieArgs = buildCookieArgs();
|
||||||
const args = [
|
const args = [
|
||||||
url,
|
"-f",
|
||||||
"--dump-single-json",
|
|
||||||
"--format",
|
|
||||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||||
|
"-o",
|
||||||
|
"-",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--no-progress",
|
||||||
// NOTE: deliberately NOT --no-simulate. Simulate mode still resolves the
|
...cookieArgs,
|
||||||
// requested format URLs into the JSON (requested_formats[].url), and it
|
"-P",
|
||||||
// avoids yt-dlp writing .part files into the process CWD — which is the
|
tmpDir,
|
||||||
// read-only Nix store dir for the deployed gateway (EACCES).
|
url,
|
||||||
];
|
];
|
||||||
|
|
||||||
logger.info({ url }, "Spawning yt-dlp for screen share input resolution");
|
logger.info({ url }, "Spawning yt-dlp for screen share input streaming");
|
||||||
|
|
||||||
const proc = spawn("yt-dlp", args, {
|
const proc = spawn("yt-dlp", args, {
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
activeProcesses.add(proc);
|
activeProcesses.add(proc);
|
||||||
|
|
||||||
let stdoutBuf = "";
|
const stream = new PassThrough();
|
||||||
|
proc.stdout.pipe(stream);
|
||||||
|
|
||||||
let stderrBuf = "";
|
let stderrBuf = "";
|
||||||
const MAX_STDERR = 4096;
|
const MAX_STDERR = 4096;
|
||||||
const MAX_STDOUT = 8 * 1024 * 1024; // JSON metadata + requested format URLs
|
proc.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
if (stderrBuf.length < MAX_STDERR) {
|
||||||
|
stderrBuf += chunk.toString("utf8");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (proc.stdout) {
|
let producedData = false;
|
||||||
proc.stdout.on("data", (chunk: Buffer) => {
|
stream.once("data", () => {
|
||||||
if (stdoutBuf.length < MAX_STDOUT) {
|
producedData = true;
|
||||||
stdoutBuf += chunk
|
});
|
||||||
.toString("utf8")
|
|
||||||
.slice(0, MAX_STDOUT - stdoutBuf.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proc.stderr) {
|
|
||||||
proc.stderr.on("data", (chunk: Buffer) => {
|
|
||||||
if (stderrBuf.length < MAX_STDERR) {
|
|
||||||
stderrBuf += chunk
|
|
||||||
.toString("utf8")
|
|
||||||
.slice(0, MAX_STDERR - stderrBuf.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.on("error", (err: NodeJS.ErrnoException) => {
|
proc.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
activeProcesses.delete(proc);
|
activeProcesses.delete(proc);
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
reject(buildNotInstalledError());
|
stream.destroy(buildNotInstalledError());
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`yt-dlp failed to start: ${err.message}`));
|
stream.destroy(new Error(`yt-dlp failed to start: ${err.message}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code) => {
|
||||||
activeProcesses.delete(proc);
|
activeProcesses.delete(proc);
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
if (code !== 0) {
|
// Fail fast: a download that dies before producing ANY bytes (e.g.
|
||||||
|
// transient YouTube 403) cannot feed the encoder — destroy the stream
|
||||||
|
// so the caller retries with a fresh yt-dlp run instead of streaming
|
||||||
|
// a silent black tile.
|
||||||
|
if (code !== 0 && !producedData && !stream.destroyed) {
|
||||||
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
||||||
reject(
|
stream.destroy(
|
||||||
new Error(
|
new Error(
|
||||||
`yt-dlp screen input resolution exited with code ${code}${detail}`,
|
`yt-dlp screen input stream failed (exit ${code})${detail}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
|
|
||||||
} catch (parseErr) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Failed to parse yt-dlp JSON for screen input: ${(parseErr as Error).message}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveScreenInput(parsed).then(resolve, (err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
reject(
|
|
||||||
new Error(`Failed to build screen input for "${url}": ${message}`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve immediately — data flows as yt-dlp downloads. The caller's
|
||||||
|
// resolveInputWithRetry validates the first byte and retries on failure.
|
||||||
|
resolve(stream);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* From a parsed yt-dlp JSON info dict, decide how to feed a single ffmpeg
|
|
||||||
* input with both video and audio.
|
|
||||||
*/
|
|
||||||
async function resolveScreenInput(
|
|
||||||
info: Record<string, unknown>,
|
|
||||||
): Promise<string | Readable> {
|
|
||||||
const requested = info.requested_formats as
|
|
||||||
| Array<Record<string, unknown>>
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
// Merged/progressive single URL (video+audio in one). Common when yt-dlp
|
|
||||||
// selects a single format (e.g. format 18 progressive mp4) or when a direct
|
|
||||||
// muxed URL is available.
|
|
||||||
const singleUrl = info.url as string | undefined;
|
|
||||||
const singleHasAudio =
|
|
||||||
info.acodec !== "none" &&
|
|
||||||
typeof info.acodec === "string" &&
|
|
||||||
info.acodec.length > 0;
|
|
||||||
|
|
||||||
if (typeof singleUrl === "string" && singleUrl && singleHasAudio) {
|
|
||||||
logger.debug("Screen share uses merged progressive single URL");
|
|
||||||
return singleUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate video-only + audio-only DASH formats → merge locally via ffmpeg.
|
|
||||||
if (Array.isArray(requested) && requested.length >= 2) {
|
|
||||||
const video = requested.find(
|
|
||||||
(rf) => rf.vcodec && String(rf.vcodec) !== "none",
|
|
||||||
);
|
|
||||||
const audio = requested.find(
|
|
||||||
(rf) => rf.acodec && String(rf.acodec) !== "none",
|
|
||||||
);
|
|
||||||
const videoUrl = video?.url as string | undefined;
|
|
||||||
const audioUrl = audio?.url as string | undefined;
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof videoUrl === "string" &&
|
|
||||||
videoUrl.length > 0 &&
|
|
||||||
typeof audioUrl === "string" &&
|
|
||||||
audioUrl.length > 0
|
|
||||||
) {
|
|
||||||
return mergeScreenStreams(videoUrl, audioUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"yt-dlp returned neither a merged progressive URL nor a video+audio format pair",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge a video-only URL and an audio-only URL into a single NUT stream using
|
|
||||||
* a child ffmpeg process. Both URLs come from the same yt-dlp run, so they
|
|
||||||
* share the same signature/expiry and are consumed immediately.
|
|
||||||
*/
|
|
||||||
function mergeScreenStreams(videoUrl: string, audioUrl: string): Readable {
|
|
||||||
logger.info("Merging video+audio DASH streams into a single NUT input");
|
|
||||||
|
|
||||||
const ffmpeg = spawn(
|
|
||||||
"ffmpeg",
|
|
||||||
[
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-reconnect",
|
|
||||||
"1",
|
|
||||||
"-reconnect_streamed",
|
|
||||||
"1",
|
|
||||||
"-reconnect_delay_max",
|
|
||||||
"5",
|
|
||||||
"-i",
|
|
||||||
videoUrl,
|
|
||||||
"-i",
|
|
||||||
audioUrl,
|
|
||||||
"-map",
|
|
||||||
"0:v:0",
|
|
||||||
"-map",
|
|
||||||
"1:a:0",
|
|
||||||
"-c:v",
|
|
||||||
"copy",
|
|
||||||
"-c:a",
|
|
||||||
"copy",
|
|
||||||
"-f",
|
|
||||||
"nut",
|
|
||||||
"pipe:1",
|
|
||||||
],
|
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track so cleanup() can terminate the merge during graceful shutdown.
|
|
||||||
activeProcesses.add(ffmpeg);
|
|
||||||
ffmpeg.once("exit", () => {
|
|
||||||
activeProcesses.delete(ffmpeg);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent the ffmpeg stderr from filling the pipe buffer / leaking.
|
|
||||||
let stderrBuf = "";
|
|
||||||
const MAX_STDERR = 4096;
|
|
||||||
ffmpeg.stderr?.on("data", (chunk: Buffer) => {
|
|
||||||
if (stderrBuf.length < MAX_STDERR) {
|
|
||||||
stderrBuf += chunk.toString("utf8");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ffmpeg.on("error", (err) => {
|
|
||||||
const msg =
|
|
||||||
err.message === "spawn ffmpeg ENOENT"
|
|
||||||
? "FFmpeg not found! Install ffmpeg in the container."
|
|
||||||
: err.message;
|
|
||||||
logger.error({ error: msg }, "Screen stream merge ffmpeg error");
|
|
||||||
});
|
|
||||||
|
|
||||||
ffmpeg.on("exit", (code) => {
|
|
||||||
const stderr = stderrBuf.trim();
|
|
||||||
logger.warn(
|
|
||||||
{ code, stderr: stderr.slice(-500) || undefined },
|
|
||||||
"Screen stream merge ffmpeg exited",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = ffmpeg.stdout;
|
|
||||||
stream.setMaxListeners(32);
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract metadata (title, duration, thumbnail) from a media URL
|
* Extract metadata (title, duration, thumbnail) from a media URL
|
||||||
* without downloading the audio stream.
|
* without downloading the audio stream.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PassThrough, type Readable } from "node:stream";
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +8,12 @@ import {
|
|||||||
prepareStream,
|
prepareStream,
|
||||||
Streamer,
|
Streamer,
|
||||||
} from "../../goLive/index.js";
|
} from "../../goLive/index.js";
|
||||||
import { getDirectScreenInput } from "./mediaSource.js";
|
import {
|
||||||
|
getDirectScreenInput,
|
||||||
|
INVIDIOUS_INSTANCES,
|
||||||
|
isYoutubeWatchUrl,
|
||||||
|
toInvidiousUrl,
|
||||||
|
} from "./mediaSource.js";
|
||||||
import type { ScreenSharePlayback } from "./mediaTypes.js";
|
import type { ScreenSharePlayback } from "./mediaTypes.js";
|
||||||
import { discordPlayer } from "./player.js";
|
import { discordPlayer } from "./player.js";
|
||||||
|
|
||||||
@@ -54,6 +60,134 @@ export class ScreenShareController {
|
|||||||
return this.active !== null;
|
return this.active !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the screen-share input with retry + first-byte validation.
|
||||||
|
*
|
||||||
|
* Transient YouTube 403s kill the merge ffmpeg BEFORE it produces any
|
||||||
|
* output; without validation the stream would "start" with a dead input
|
||||||
|
* and show a black tile forever. So after getDirectScreenInput resolves we
|
||||||
|
* tee the stream through a PassThrough and wait for the FIRST readable
|
||||||
|
* byte (or an error / early EOF). On failure the whole resolution is
|
||||||
|
* retried with a FRESH yt-dlp run (signed DASH URLs expire quickly — the
|
||||||
|
* old URLs cannot simply be re-fetched).
|
||||||
|
*/
|
||||||
|
private async resolveInputWithRetry(source: string): Promise<Readable> {
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
// YouTube may 403 even with account cookies (IP-bound session / bot check
|
||||||
|
// on VPS IP). When the source is a YouTube URL and cookies fail, fall back
|
||||||
|
// to anon Invidious mirror instances — no auth needed.
|
||||||
|
const isYt = isYoutubeWatchUrl(source);
|
||||||
|
let invidiousIdx = 0;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
|
// On a 403 against YouTube, try the next Invidious instance for this attempt.
|
||||||
|
if (
|
||||||
|
isYt &&
|
||||||
|
lastError &&
|
||||||
|
/403|bot|Sign in|not a bot|access denied/i.test(lastError.message) &&
|
||||||
|
invidiousIdx < INVIDIOUS_INSTANCES.length
|
||||||
|
) {
|
||||||
|
const inst = INVIDIOUS_INSTANCES[invidiousIdx];
|
||||||
|
this.logger.warn(
|
||||||
|
{ attempt, instance: inst, error: lastError.message },
|
||||||
|
"YouTube blocked (403); falling back to Invidious mirror",
|
||||||
|
);
|
||||||
|
source = toInvidiousUrl(source, inst);
|
||||||
|
invidiousIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = await getDirectScreenInput(source);
|
||||||
|
|
||||||
|
const tee = new PassThrough();
|
||||||
|
input.on("error", (err) => tee.destroy(err));
|
||||||
|
input.on("end", () => tee.end());
|
||||||
|
input.pipe(tee);
|
||||||
|
// If the merge process is stuck (no data, no exit) destroy the raw
|
||||||
|
// stream too so ffmpeg gets EPIPE on its next write and dies —
|
||||||
|
// otherwise every failed attempt leaks a merge process.
|
||||||
|
const destroyInput = () => {
|
||||||
|
try {
|
||||||
|
input.destroy();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
destroyInput();
|
||||||
|
// Listeners were just removed by cleanup() — destroying tee WITH
|
||||||
|
// an error would emit "error" on an unlistened PassThrough and
|
||||||
|
// surface as an unhandled 'error' event (crash). Destroy
|
||||||
|
// silently; the error lives in the rejection only.
|
||||||
|
tee.destroy();
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Screen input produced no data within 12s — merge likely failed",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 12000);
|
||||||
|
const onReadable = () => {
|
||||||
|
if (tee.readableLength > 0) {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
// readableLength === 0 can mean "EOF reached" — handled by onEnd.
|
||||||
|
};
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
const onEnd = () => {
|
||||||
|
cleanup();
|
||||||
|
destroyInput();
|
||||||
|
reject(new Error("Screen input ended before producing any data"));
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
tee.removeListener("readable", onReadable);
|
||||||
|
tee.removeListener("error", onError);
|
||||||
|
tee.removeListener("end", onEnd);
|
||||||
|
};
|
||||||
|
tee.once("readable", onReadable);
|
||||||
|
tee.once("error", onError);
|
||||||
|
tee.once("end", onEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safety net: cleanup() removes the once() listeners on timeout/error,
|
||||||
|
// but a late error event from input.pipe(tee) can still fire on an
|
||||||
|
// unlistened PassThrough and crash the gateway (unhandled 'error').
|
||||||
|
// A permanent no-op listener guarantees the event is always swallowed.
|
||||||
|
tee.on("error", () => {});
|
||||||
|
// Pass the tee onward — the encoder consumes the same buffered
|
||||||
|
// stream, so no data from the merge is lost.
|
||||||
|
return tee;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
attempt,
|
||||||
|
maxAttempts: MAX_ATTEMPTS,
|
||||||
|
error: lastError.message,
|
||||||
|
},
|
||||||
|
"Screen input resolution failed; retrying with fresh yt-dlp",
|
||||||
|
);
|
||||||
|
if (attempt < MAX_ATTEMPTS) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (
|
||||||
|
lastError ??
|
||||||
|
new Error("Screen input resolution failed after multiple attempts")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async start(source: string): Promise<ScreenSharePlayback> {
|
async start(source: string): Promise<ScreenSharePlayback> {
|
||||||
const status = this.getVoiceStatus();
|
const status = this.getVoiceStatus();
|
||||||
if (!status.connected || !status.activeGuildId || !status.activeChannelId) {
|
if (!status.connected || !status.activeGuildId || !status.activeChannelId) {
|
||||||
@@ -65,7 +199,7 @@ export class ScreenShareController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const input = await getDirectScreenInput(source);
|
const input = await this.resolveInputWithRetry(source);
|
||||||
if (!this.streamer) {
|
if (!this.streamer) {
|
||||||
this.streamer = new Streamer(this.client);
|
this.streamer = new Streamer(this.client);
|
||||||
}
|
}
|
||||||
@@ -105,6 +239,12 @@ export class ScreenShareController {
|
|||||||
frameRate: 30,
|
frameRate: 30,
|
||||||
bitrateVideo: 2500,
|
bitrateVideo: 2500,
|
||||||
bitrateVideoMax: 4000,
|
bitrateVideoMax: 4000,
|
||||||
|
// GoLive with audio: the encoder muxes to NUT (video h264 + opus
|
||||||
|
// audio) so the audio SSRC carries RTP too. Discord's GoLive
|
||||||
|
// pipeline expects audio — a video-only stream shows a static
|
||||||
|
// tile/thumbnail instead of live video. When the source has no
|
||||||
|
// audio track, the encoder's `-map 0:a:0?` yields no audio stream
|
||||||
|
// and the demuxer simply reports none (video still flows).
|
||||||
includeAudio: true,
|
includeAudio: true,
|
||||||
videoCodec: normalizeVideoCodec("H264"),
|
videoCodec: normalizeVideoCodec("H264"),
|
||||||
});
|
});
|
||||||
@@ -145,6 +285,9 @@ export class ScreenShareController {
|
|||||||
};
|
};
|
||||||
const done = playStream(prepared, this.streamer, {
|
const done = playStream(prepared, this.streamer, {
|
||||||
type: "go-live",
|
type: "go-live",
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
frameRate: 30,
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
// Never let a stream failure become an unhandledRejection — that
|
// Never let a stream failure become an unhandledRejection — that
|
||||||
|
|||||||
@@ -56,6 +56,24 @@ export class VoiceTransmitter {
|
|||||||
// Create PCM input stream
|
// Create PCM input stream
|
||||||
this.pcmStream = new PassThrough();
|
this.pcmStream = new PassThrough();
|
||||||
this.pcmStream.setMaxListeners(32); // drain listeners accumulate during backpressure
|
this.pcmStream.setMaxListeners(32); // drain listeners accumulate during backpressure
|
||||||
|
// Voice teardown (stop / disconnect / ffmpeg exit) destroys this stream
|
||||||
|
// while Redis PCM messages may still be in flight. Without a listener,
|
||||||
|
// EPIPE / ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END surface as
|
||||||
|
// an uncaughtException and crash the whole gateway.
|
||||||
|
this.pcmStream.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (
|
||||||
|
err.code === "EPIPE" ||
|
||||||
|
err.code === "ERR_STREAM_DESTROYED" ||
|
||||||
|
err.code === "ERR_STREAM_WRITE_AFTER_END"
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
{ code: err.code },
|
||||||
|
"PCM stream closed during voice teardown — ignoring",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error({ error: err.message }, "PCM stream error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Spawn FFmpeg to encode 24kHz mono PCM → OggOpus
|
// Spawn FFmpeg to encode 24kHz mono PCM → OggOpus
|
||||||
// Input: 24kHz mono s16le (raw PCM)
|
// Input: 24kHz mono s16le (raw PCM)
|
||||||
@@ -146,7 +164,12 @@ export class VoiceTransmitter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.redisSub.on("message", (channel, message) => {
|
this.redisSub.on("message", (channel, message) => {
|
||||||
if (channel !== this.TRANSMIT_CHANNEL || !this.pcmStream) return;
|
if (
|
||||||
|
!this.isActive ||
|
||||||
|
channel !== this.TRANSMIT_CHANNEL ||
|
||||||
|
!this.pcmStream
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(message);
|
const data = JSON.parse(message);
|
||||||
@@ -161,11 +184,21 @@ export class VoiceTransmitter {
|
|||||||
this.draining = false;
|
this.draining = false;
|
||||||
// Re-acquire stream reference (could have been replaced by restart)
|
// Re-acquire stream reference (could have been replaced by restart)
|
||||||
const currentStream = this.pcmStream;
|
const currentStream = this.pcmStream;
|
||||||
if (!currentStream) return;
|
if (!currentStream || !this.isActive) return;
|
||||||
// Flush queued chunks
|
// Flush queued chunks
|
||||||
while (this.backpressureQueue.length > 0) {
|
while (this.backpressureQueue.length > 0) {
|
||||||
const queued = this.backpressureQueue.shift()!;
|
const queued = this.backpressureQueue.shift()!;
|
||||||
if (!currentStream.write(queued)) break;
|
try {
|
||||||
|
if (!currentStream.write(queued)) break;
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"PCM flush write failed during teardown — ignoring",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ export const configSchema = z
|
|||||||
|
|
||||||
// ── Redis ────────────────────────────────────────────────────────────
|
// ── Redis ────────────────────────────────────────────────────────────
|
||||||
REDIS_URL: z.string().default("redis://localhost:6379"),
|
REDIS_URL: z.string().default("redis://localhost:6379"),
|
||||||
|
// ── SearXNG ───────────────────────────────────────────────────────────
|
||||||
|
// Instance for web search + term glossary lookups. Override when the
|
||||||
|
// default instance is down/rate-limited.
|
||||||
|
SEARXNG_BASE_URL: z.string().url().default("https://searxng.imrnes.team"),
|
||||||
// ── Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ────
|
// ── Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ────
|
||||||
VOICE_PCM_WS_ENABLED: z
|
VOICE_PCM_WS_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
@@ -171,6 +175,24 @@ export const configSchema = z
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.default(30000),
|
.default(30000),
|
||||||
|
// Term glossary — per-word Wikipedia lookups (via SearXNG) for words the
|
||||||
|
// LLM may not know (slang, jargon, regional language, foreign terms).
|
||||||
|
// Definitions are cached (in-memory + Redis) so repeat lookups are fast.
|
||||||
|
// Disable to skip glossary lookups entirely and analyze without them.
|
||||||
|
AI_GLOSSARY_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.default(true),
|
||||||
|
// Max glossary terms looked up per analysis batch (keeps latency bounded).
|
||||||
|
AI_GLOSSARY_MAX_TERMS: z.coerce.number().int().min(1).max(20).default(6),
|
||||||
|
// Min word length for a term to be considered glossary-worthy.
|
||||||
|
AI_GLOSSARY_MIN_WORD_LENGTH: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(2)
|
||||||
|
.max(20)
|
||||||
|
.default(5),
|
||||||
|
|
||||||
// ── AI Analysis Timing ──────────────────────────────────────────────
|
// ── AI Analysis Timing ──────────────────────────────────────────────
|
||||||
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
||||||
|
|||||||
@@ -435,6 +435,32 @@ export const pgStickerCacheTable = pgTable(
|
|||||||
|
|
||||||
export const stickerCacheTable = pgStickerCacheTable;
|
export const stickerCacheTable = pgStickerCacheTable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Term Glossary Cache Table (PostgreSQL)
|
||||||
|
* Permanently stores resolved term definitions (Wikipedia/SearXNG lookups).
|
||||||
|
* Definitions rarely change, so once a term is successfully resolved it is
|
||||||
|
* persisted here forever — Redis/LRU only act as fast read caches in front.
|
||||||
|
* Terms with NO definition (misses) are NOT stored here; they stay ephemeral
|
||||||
|
* in Redis with a short TTL so transient lookup failures get retried.
|
||||||
|
*/
|
||||||
|
export const pgTermGlossaryCacheTable = pgTable(
|
||||||
|
"term_glossary_cache",
|
||||||
|
{
|
||||||
|
term: pgText("term").primaryKey(),
|
||||||
|
definition: pgText("definition").notNull(),
|
||||||
|
source_url: pgText("source_url").notNull().default(""),
|
||||||
|
resolved_at: pgBigint("resolved_at", { mode: "number" }).notNull(),
|
||||||
|
hit_count: pgInteger("hit_count").notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
resolvedAtIdx: pgIndex("idx_term_glossary_cache_resolved_at").on(
|
||||||
|
table.resolved_at,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const termGlossaryCacheTable = pgTermGlossaryCacheTable;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Meta / System
|
// Meta / System
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -580,6 +606,11 @@ export type TextAnalysisCacheInsert =
|
|||||||
export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
|
export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
|
||||||
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
|
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
|
||||||
|
|
||||||
|
// Term Glossary Cache
|
||||||
|
export type TermGlossaryCache = typeof termGlossaryCacheTable.$inferSelect;
|
||||||
|
export type TermGlossaryCacheInsert =
|
||||||
|
typeof termGlossaryCacheTable.$inferInsert;
|
||||||
|
|
||||||
// Muxer Jobs
|
// Muxer Jobs
|
||||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||||
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
||||||
|
|||||||
@@ -108,5 +108,8 @@ export async function retryWithBackoff<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError!;
|
// lastError is always set: the for-loop only exits via break when attempt
|
||||||
|
// === retries, which only happens in the catch branch that sets lastError.
|
||||||
|
if (!lastError) throw new Error("Unknown retry error");
|
||||||
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import { demux } from "../src/goLive/Demuxer.js";
|
||||||
|
|
||||||
|
/** Same resolution as Demuxer.resolveBin: env override → Nix store scan. */
|
||||||
|
function resolveFfmpeg(): string | null {
|
||||||
|
const override = process.env.FFMPEG_PATH;
|
||||||
|
if (override && existsSync(override)) return override;
|
||||||
|
const store = "/nix/store";
|
||||||
|
if (existsSync(store)) {
|
||||||
|
for (const entry of readdirSync(store)) {
|
||||||
|
if (!entry.includes("ffmpeg-headless-")) continue;
|
||||||
|
const candidate = join(store, entry, "bin", "ffmpeg");
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existsSync("/usr/bin/ffmpeg") ? "/usr/bin/ffmpeg" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ogg Opus byte stream built by hand: OpusHead (19B) + a few 20ms opus
|
||||||
|
* frames, wrapped in valid Ogg pages (CRC 0 — the parser ignores CRC).
|
||||||
|
*/
|
||||||
|
function buildOggOpusBytes(frames: number): Buffer {
|
||||||
|
const opusHead = Buffer.from([
|
||||||
|
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
||||||
|
0x01, 0x02, 0x38, 0x01, 0x80, 0xbb, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, // version 1, 2ch, 48000
|
||||||
|
]);
|
||||||
|
// A minimal valid opus data packet: TOC 0xFC (48kHz stereo 20ms) + payload
|
||||||
|
const dataPacket = Buffer.alloc(20, 0);
|
||||||
|
dataPacket[0] = 0xfc;
|
||||||
|
const makePage = (
|
||||||
|
seq: number,
|
||||||
|
headerType: number,
|
||||||
|
serial: number,
|
||||||
|
packets: Buffer[],
|
||||||
|
): Buffer => {
|
||||||
|
const segmentTable: number[] = [];
|
||||||
|
const payloadParts: Buffer[] = [];
|
||||||
|
for (const p of packets) {
|
||||||
|
let remaining = p.length;
|
||||||
|
let off = 0;
|
||||||
|
do {
|
||||||
|
const chunk = Math.min(255, remaining);
|
||||||
|
segmentTable.push(chunk);
|
||||||
|
payloadParts.push(p.subarray(off, off + chunk));
|
||||||
|
off += chunk;
|
||||||
|
remaining -= chunk;
|
||||||
|
} while (remaining > 0);
|
||||||
|
}
|
||||||
|
const payload = Buffer.concat(payloadParts);
|
||||||
|
const header = Buffer.alloc(27 + segmentTable.length);
|
||||||
|
header.write("OggS", 0, "latin1");
|
||||||
|
header[4] = 0; // version
|
||||||
|
header[5] = headerType;
|
||||||
|
header.writeUInt32LE(0, 6); // granule (unused)
|
||||||
|
header.writeUInt32LE(0, 10);
|
||||||
|
header.writeUInt32LE(serial, 14);
|
||||||
|
header.writeUInt32LE(seq, 18);
|
||||||
|
header.writeUInt32LE(0, 22); // crc (ignored)
|
||||||
|
header[26] = segmentTable.length;
|
||||||
|
for (let i = 0; i < segmentTable.length; i++) header[27 + i] = segmentTable[i];
|
||||||
|
return Buffer.concat([header, payload]);
|
||||||
|
};
|
||||||
|
const pages: Buffer[] = [];
|
||||||
|
const serial = 0x1234;
|
||||||
|
let seq = 0;
|
||||||
|
// Page 0: BOS + OpusHead (19 bytes, single lacing)
|
||||||
|
pages.push(makePage(seq++, 0x02, serial, [opusHead]));
|
||||||
|
// Pages 1+: data packets, a few per page
|
||||||
|
const perPage = 3;
|
||||||
|
for (let i = 0; i < frames; i += perPage) {
|
||||||
|
const pkts = [];
|
||||||
|
for (let j = 0; j < perPage && i + j < frames; j++) pkts.push(dataPacket);
|
||||||
|
pages.push(makePage(seq++, 0x00, serial, pkts));
|
||||||
|
}
|
||||||
|
return Buffer.concat(pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Demuxer NUT path with audio", () => {
|
||||||
|
it("emits video access units AND parsed opus audio frames", async () => {
|
||||||
|
// Real NUT file (video h264 + opus) produced by ffmpeg — generated once
|
||||||
|
// in this test via ffmpeg, skipped if ffmpeg is unavailable.
|
||||||
|
const ffmpeg = resolveFfmpeg();
|
||||||
|
if (!ffmpeg) {
|
||||||
|
console.warn("ffmpeg not found — skipping NUT integration case");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "gmw-nut-test-"));
|
||||||
|
const inWebm = join(dir, "in.webm");
|
||||||
|
const inNut = join(dir, "in.nut");
|
||||||
|
try {
|
||||||
|
// Build a tiny webm (vpx + opus) then remux to NUT h264+opus — mirrors
|
||||||
|
// prepareStream(includeAudio) output.
|
||||||
|
const { spawnSync } = await import("node:child_process");
|
||||||
|
const gen = spawnSync(
|
||||||
|
ffmpeg,
|
||||||
|
[
|
||||||
|
"-hide_banner", "-loglevel", "error",
|
||||||
|
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=3",
|
||||||
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
|
||||||
|
"-c:v", "libvpx-vp9", "-b:v", "100k", "-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "libopus", "-b:a", "48k", "-f", "webm", "-y", inWebm,
|
||||||
|
],
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
if (gen.status !== 0) {
|
||||||
|
console.warn("ffmpeg webm gen failed — skipping", gen.stderr?.toString().slice(0, 200));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enc = spawnSync(
|
||||||
|
ffmpeg,
|
||||||
|
[
|
||||||
|
"-hide_banner", "-loglevel", "error", "-i", inWebm,
|
||||||
|
"-map", "0:v:0", "-c:v", "libx264", "-profile:v", "baseline",
|
||||||
|
"-x264-params", "repeat-headers=1", "-preset", "superfast",
|
||||||
|
"-pix_fmt", "yuv420p", "-g", "10", "-forced-idr", "1",
|
||||||
|
"-map", "0:a:0?", "-c:a", "libopus", "-b:a", "48k", "-ar", "48000", "-ac", "2",
|
||||||
|
"-f", "nut", "-y", inNut,
|
||||||
|
],
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
if (enc.status !== 0) {
|
||||||
|
console.warn("ffmpeg nut gen failed — skipping", enc.stderr?.toString().slice(0, 200));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { readFileSync } = await import("node:fs");
|
||||||
|
// demux() takes string | PassThrough — wrap the file read. Drip the
|
||||||
|
// bytes in quickly (well within demux's 1.5s metadata window) so ffmpeg
|
||||||
|
// prints both stream lines before demux() resolves.
|
||||||
|
const input = new PassThrough();
|
||||||
|
const nutBytes = readFileSync(inNut);
|
||||||
|
const CHUNK = Math.max(1024, Math.floor(nutBytes.length / 20));
|
||||||
|
let off = 0;
|
||||||
|
const drip = setInterval(() => {
|
||||||
|
if (off >= nutBytes.length) {
|
||||||
|
clearInterval(drip);
|
||||||
|
input.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.write(nutBytes.subarray(off, off + CHUNK));
|
||||||
|
off += CHUNK;
|
||||||
|
}, 10);
|
||||||
|
const { video, audio, close } = await demux(input, {
|
||||||
|
format: "nut",
|
||||||
|
frameRate: 10,
|
||||||
|
});
|
||||||
|
const vFrames: number[] = [];
|
||||||
|
const aFrames: number[] = [];
|
||||||
|
video?.stream.on("data", (f: { data: Buffer | null; flags: number }) => {
|
||||||
|
if (f.data) vFrames.push(f.data.length);
|
||||||
|
});
|
||||||
|
audio?.stream.on("data", (f: { data: Buffer | null; duration: number }) => {
|
||||||
|
if (f.data) aFrames.push(f.duration);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
video?.stream.on("end", resolve);
|
||||||
|
setTimeout(resolve, 6000);
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
expect(vFrames.length).toBeGreaterThan(0);
|
||||||
|
// ~10fps × 1s of video → at least 5 access units
|
||||||
|
expect(vFrames.length).toBeGreaterThanOrEqual(5);
|
||||||
|
// ~50 opus frames/sec of audio
|
||||||
|
expect(aFrames.length).toBeGreaterThan(10);
|
||||||
|
// opus frames are 20ms (duration 960 @ 48kHz)
|
||||||
|
expect(aFrames[0]).toBe(960);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("parses a hand-built Ogg Opus stream into frames", async () => {
|
||||||
|
// Feed the OGG bytes via the demux's audio path is not directly
|
||||||
|
// exposed — instead verify the parser contract through the NUT path is
|
||||||
|
// covered above; here we sanity-check the byte layout our parser reads.
|
||||||
|
const bytes = buildOggOpusBytes(7);
|
||||||
|
expect(bytes.subarray(0, 4).toString("latin1")).toBe("OggS");
|
||||||
|
// 7 frames + header across pages
|
||||||
|
expect(bytes.includes(Buffer.from("OpusHead"))).toBe(true);
|
||||||
|
expect(bytes.subarray(28, 36).toString("latin1")).toBe("OpusHead");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,11 +22,14 @@ describe("goLive port: codec + encoders", () => {
|
|||||||
expect(normalizeVideoCodec("av1")).toBe("AV1");
|
expect(normalizeVideoCodec("av1")).toBe("AV1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("software encoder exposes x264 libx264 superfast film", () => {
|
it("software encoder exposes x264 libx264 baseline zerolatency", () => {
|
||||||
const enc = Encoders.software()();
|
const enc = Encoders.software()();
|
||||||
expect(enc.H264.name).toBe("libx264");
|
expect(enc.H264.name).toBe("libx264");
|
||||||
expect(enc.H264.options).toContain("-preset superfast");
|
expect(enc.H264.options).toContain("-preset superfast");
|
||||||
expect(enc.H264.options).toContain("-tune film");
|
expect(enc.H264.options).toContain("-tune zerolatency");
|
||||||
|
// Baseline profile is REQUIRED to match the SDP's profile-level-id=42e01f
|
||||||
|
// (constrained baseline) — High-profile bitstreams fail to decode → black
|
||||||
|
expect(enc.H264.options).toContain("-profile:v baseline");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CodecPayloadType has opus + H264 entries", () => {
|
it("CodecPayloadType has opus + H264 entries", () => {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Phase 2 E2E: Demuxer on a real ffmpeg-generated H264 file.
|
||||||
|
// Run: npx tsx tests/golive-demux-e2e.ts
|
||||||
|
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { demux } from "../src/goLive/Demuxer.js";
|
||||||
|
|
||||||
|
const input = process.argv[2] ?? "/tmp/sample.h264";
|
||||||
|
const { video, close } = await demux(createReadStream(input), {
|
||||||
|
format: "h264",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"video:",
|
||||||
|
JSON.stringify({
|
||||||
|
codecName: video.codecName,
|
||||||
|
width: video.width,
|
||||||
|
height: video.height,
|
||||||
|
duration: video.duration,
|
||||||
|
fps: Math.round(video.framerate_num / video.framerate_den),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let keyframes = 0;
|
||||||
|
let bytes = 0;
|
||||||
|
video.stream.on("data", (frame: { data: Buffer; keyframe: boolean }) => {
|
||||||
|
count++;
|
||||||
|
bytes += frame.data.length;
|
||||||
|
if (frame.keyframe) keyframes++;
|
||||||
|
});
|
||||||
|
video.stream.on("end", () => {
|
||||||
|
console.log(`frames: ${count} (${keyframes} keyframes), ${bytes} bytes`);
|
||||||
|
close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
video.stream.on("error", (e: unknown) => {
|
||||||
|
console.error("stream error:", e);
|
||||||
|
close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Regression test: demux must emit frames from a LIVE stream that never
|
||||||
|
// ends (the NUT/H264 merge output during playback). The old implementation
|
||||||
|
// spooled the whole stream to a file first → deadlocked forever → 0 frames.
|
||||||
|
// v2: also validates ACCESS-UNIT grouping — each emitted frame must be a
|
||||||
|
// complete picture (parameter sets + slice), never a bare SPS/PPS/SEI NAL,
|
||||||
|
// and must be timestamped at the video frame rate (RTP +clockRate/fps).
|
||||||
|
// Run: npx tsx tests/golive-demux-live-e2e.ts [ffmpeg-path]
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import { demux } from "../src/goLive/Demuxer.js";
|
||||||
|
|
||||||
|
const FFMPEG = process.argv[2] ?? "ffmpeg";
|
||||||
|
|
||||||
|
// 1) Generate a 2s H264 test clip to a temp file
|
||||||
|
const clip = "/tmp/golive-live-test.h264";
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const p = spawn(
|
||||||
|
FFMPEG,
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"testsrc=size=640x360:rate=30:duration=2",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"ultrafast",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-f",
|
||||||
|
"h264",
|
||||||
|
clip,
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||||
|
);
|
||||||
|
let err = "";
|
||||||
|
p.stderr?.on("data", (d: Buffer) => (err += d.toString()));
|
||||||
|
p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(err))));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Feed the clip through a PassThrough but DON'T end it (live semantics),
|
||||||
|
// with a small pause after the first chunk so demux has time to emit.
|
||||||
|
const input = new PassThrough();
|
||||||
|
const demuxPromise = demux(input, { format: "h264", frameRate: 30 });
|
||||||
|
const { video, close } = await demuxPromise;
|
||||||
|
if (!video) {
|
||||||
|
console.error("FAIL: demux returned no video stream");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emitted {
|
||||||
|
data: Buffer;
|
||||||
|
duration: number;
|
||||||
|
timeBase: { num: number; den: number };
|
||||||
|
flags: number;
|
||||||
|
}
|
||||||
|
const frames: Emitted[] = [];
|
||||||
|
video.stream.on("data", (frame: Emitted) => {
|
||||||
|
frames.push(frame);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fs = await import("node:fs");
|
||||||
|
const buf = fs.readFileSync(clip);
|
||||||
|
const chunkSize = 16384;
|
||||||
|
for (let i = 0; i < buf.length; i += chunkSize) {
|
||||||
|
input.write(buf.subarray(i, i + chunkSize));
|
||||||
|
if (i === 0) await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
// Stream still open — if the old spool logic was here we'd never emit.
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// 3) Validate access-unit structure
|
||||||
|
const nalTypes = (frame: Buffer): number[] => {
|
||||||
|
const out: number[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < frame.length - 3) {
|
||||||
|
if (frame[i] === 0 && frame[i + 1] === 0 && frame[i + 2] === 1) {
|
||||||
|
const start = i;
|
||||||
|
let j = i + 3;
|
||||||
|
if (frame[j - 4] === 0 && j >= 4) {
|
||||||
|
// 4-byte start code already consumed by i pointing at the 3-byte tail
|
||||||
|
}
|
||||||
|
while (j < frame.length - 3) {
|
||||||
|
if (frame[j] === 0 && frame[j + 1] === 0 && frame[j + 2] === 1) break;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
const nal = frame.subarray(start + 3, j);
|
||||||
|
if (nal.length > 0) out.push(nal[0] & 0x1f);
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
let bareParamSetFrames = 0;
|
||||||
|
let framesWithoutSlice = 0;
|
||||||
|
let keyframesWithParamSets = 0;
|
||||||
|
let keyframesWithoutParamSets = 0;
|
||||||
|
for (const f of frames) {
|
||||||
|
const types = nalTypes(f.data);
|
||||||
|
const hasSlice = types.some((t) => t === 1 || t === 5);
|
||||||
|
const hasParams = types.some((t) => t === 7 || t === 8);
|
||||||
|
const isKey = (f.flags & 1) !== 0;
|
||||||
|
if (!hasSlice) framesWithoutSlice++;
|
||||||
|
if (
|
||||||
|
types.length === 1 &&
|
||||||
|
(types[0] === 7 || types[0] === 8 || types[0] === 6)
|
||||||
|
) {
|
||||||
|
bareParamSetFrames++;
|
||||||
|
}
|
||||||
|
if (isKey && hasParams) keyframesWithParamSets++;
|
||||||
|
if (isKey && !hasParams) keyframesWithoutParamSets++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`metadata: ${video.codecName} ${video.width}x${video.height} fps=${video.framerate_num}/${video.framerate_den}`,
|
||||||
|
);
|
||||||
|
console.log(`frames while stream OPEN (not ended): ${frames.length}`);
|
||||||
|
console.log(
|
||||||
|
`frames w/o slice NAL: ${framesWithoutSlice}, bare param-set frames: ${bareParamSetFrames}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`keyframes with SPS/PPS: ${keyframesWithParamSets}, without: ${keyframesWithoutParamSets}`,
|
||||||
|
);
|
||||||
|
if (frames.length === 0) {
|
||||||
|
console.error("FAIL: no frames emitted while input still open (deadlock)");
|
||||||
|
close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (bareParamSetFrames > 0 || framesWithoutSlice > 0) {
|
||||||
|
console.error(
|
||||||
|
"FAIL: demux emitted bare parameter-set frames (must group into access units)",
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (frames.some((f) => f.duration !== 1 || f.timeBase.den !== 30)) {
|
||||||
|
console.error(
|
||||||
|
"FAIL: frame duration/timeBase not 1/30 (RTP timestamp advance wrong)",
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
input.end();
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
close();
|
||||||
|
console.log("PASS: live stream demux works + access units grouped correctly");
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Phase 2 E2E: full pipeline prepareStream → demux → frame stream.
|
||||||
|
// Run: npx tsx tests/golive-pipeline-e2e.ts
|
||||||
|
|
||||||
|
import { demux } from "../src/goLive/Demuxer.js";
|
||||||
|
import { Encoders } from "../src/goLive/Encoders.js";
|
||||||
|
import { prepareStream } from "../src/goLive/prepareStream.js";
|
||||||
|
import { normalizeVideoCodec } from "../src/goLive/utils.js";
|
||||||
|
|
||||||
|
// Use a real ffmpeg-generated video file as input (from sample generation).
|
||||||
|
const input = process.argv[2] ?? "/tmp/sample.h264";
|
||||||
|
|
||||||
|
const prepared = prepareStream(input, {
|
||||||
|
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
frameRate: 25,
|
||||||
|
bitrateVideo: 500,
|
||||||
|
bitrateVideoMax: 800,
|
||||||
|
includeAudio: false,
|
||||||
|
videoCodec: normalizeVideoCodec("H264"),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"prepareStream ok, videoCodec:",
|
||||||
|
prepared.videoCodec,
|
||||||
|
"size:",
|
||||||
|
prepared.width,
|
||||||
|
"x",
|
||||||
|
prepared.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { video, close } = await demux(prepared.output, { format: "h264" });
|
||||||
|
console.log("demux video:", video?.codecName, video?.width, "x", video?.height);
|
||||||
|
|
||||||
|
let frames = 0;
|
||||||
|
let keyframes = 0;
|
||||||
|
video.stream.on("data", (f: { keyframe?: boolean }) => {
|
||||||
|
frames++;
|
||||||
|
if (f.keyframe) keyframes++;
|
||||||
|
});
|
||||||
|
video.stream.on("end", () => {
|
||||||
|
console.log(`pipeline frames: ${frames} (${keyframes} keyframes)`);
|
||||||
|
close();
|
||||||
|
prepared.command.kill("SIGTERM");
|
||||||
|
process.exit(frames > 0 ? 0 : 1);
|
||||||
|
});
|
||||||
|
video.stream.on("error", (e: unknown) => {
|
||||||
|
console.error("pipeline error:", e);
|
||||||
|
close();
|
||||||
|
prepared.command.kill("SIGTERM");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("timeout after 30s — killing");
|
||||||
|
close();
|
||||||
|
prepared.command.kill("SIGTERM");
|
||||||
|
process.exit(2);
|
||||||
|
}, 30000);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Phase 2 E2E: demux → VideoStream → native packetizer chain (local pair).
|
||||||
|
// Run: npx tsx tests/golive-videostream-e2e.ts
|
||||||
|
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { demux } from "../src/goLive/Demuxer.js";
|
||||||
|
import { loadNative } from "../src/goLive/native.js";
|
||||||
|
import { VideoStream } from "../src/goLive/VideoStream.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const native = loadNative();
|
||||||
|
const { PeerConnection } = native;
|
||||||
|
|
||||||
|
const pcA = new PeerConnection({ iceServers: [] });
|
||||||
|
const pcB = new PeerConnection({ iceServers: [] });
|
||||||
|
|
||||||
|
pcA.onStateChange(() => {});
|
||||||
|
pcB.onStateChange(() => {});
|
||||||
|
|
||||||
|
// Both peers declare audio+video tracks (exact passing test-packetizer
|
||||||
|
// pattern — tracks trigger negotiation).
|
||||||
|
pcA.addTrack("0", "audio");
|
||||||
|
pcA.addTrack("1", "video");
|
||||||
|
pcB.addTrack("0", "audio");
|
||||||
|
const trackB = pcB.addTrack("1", "video");
|
||||||
|
if (!trackB) throw new Error("no track from addTrack");
|
||||||
|
|
||||||
|
const track = trackB;
|
||||||
|
// NOTE: setPacketizer is called AFTER connected (see below) — calling it
|
||||||
|
// before negotiation breaks the offer (libdatachannel negotiation state).
|
||||||
|
|
||||||
|
const offer = await pcA.createOffer();
|
||||||
|
console.log("T1 offer");
|
||||||
|
pcB.setRemoteDescription(offer, "offer");
|
||||||
|
const answer = await pcB.createAnswer(offer);
|
||||||
|
console.log("T2 answer");
|
||||||
|
pcA.setRemoteDescription(answer, "answer");
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
console.log("T3 states:", pcA.state(), "/", pcB.state());
|
||||||
|
|
||||||
|
// Discord-style SSRC/payload: H264 101 @ 90kHz, playout ext id 5
|
||||||
|
track.setPacketizer("h264", 0x1234, 101, 90000, 5, 0, 10);
|
||||||
|
|
||||||
|
const { video, close } = await demux(createReadStream("/tmp/sample.h264"), {
|
||||||
|
format: "h264",
|
||||||
|
});
|
||||||
|
console.log("video stream:", video.codecName, video.width, "x", video.height);
|
||||||
|
|
||||||
|
const conn = {
|
||||||
|
sendVideoFrame: (frame: Buffer, frametime: number) => {
|
||||||
|
track.sendFrame(frame);
|
||||||
|
track.addTimestamp(Math.round((frametime * 90000) / 1000));
|
||||||
|
},
|
||||||
|
} as unknown as { sendVideoFrame(frame: Buffer, frametime: number): void };
|
||||||
|
|
||||||
|
const vStream = new VideoStream(conn as never);
|
||||||
|
let sent = 0;
|
||||||
|
const origSend = conn.sendVideoFrame;
|
||||||
|
conn.sendVideoFrame = (frame: Buffer, frametime: number) => {
|
||||||
|
sent++;
|
||||||
|
origSend(frame, frametime);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.stream.pipe(vStream);
|
||||||
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
|
|
||||||
|
console.log(`sent ${sent} frames via VideoStream; B state=${pcB.state()}`);
|
||||||
|
const ok = sent > 0 && pcB.state() === "connected";
|
||||||
|
close();
|
||||||
|
pcA.close();
|
||||||
|
pcB.close();
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("E2E failed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// makeImageCacheKey — regression for hash collision bug
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Bug (2026-08-12): makeImageCacheKey() only hashed the first 128 chars of the
|
||||||
|
// data URL. Since all resized images share the same MIME prefix
|
||||||
|
// ('data:image/png;base64,') + identical base64 header bytes, nearly every
|
||||||
|
// image got the SAME hash → 'image:<same-hash>' → all images reused the
|
||||||
|
// first cached vision analysis ("konten judi").
|
||||||
|
//
|
||||||
|
// Fix: hash the ENTIRE data URL. This test verifies the fix and prevents
|
||||||
|
// regression.
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { makeImageCacheKey } from "../src/modules/ai-moderation/textCacheStore.js";
|
||||||
|
|
||||||
|
function oldBuggyHash(dataUrl: string): string {
|
||||||
|
const prefix = dataUrl.slice(0, 128);
|
||||||
|
return "image:" + createHash("sha256").update(prefix).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("makeImageCacheKey — collision prevention", () => {
|
||||||
|
it("produces different keys for images whose first 128 chars are identical", () => {
|
||||||
|
// Two data URLs that SHARE the first 128 chars (same MIME + identical
|
||||||
|
// base64 header) but differ after — this is the real-world scenario
|
||||||
|
// that caused the collision bug.
|
||||||
|
const sharedPrefix =
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; // pad to >128 chars
|
||||||
|
|
||||||
|
const imgA = sharedPrefix + "UNIQUE_TO_A";
|
||||||
|
const imgB = sharedPrefix + "UNIQUE_TO_B";
|
||||||
|
|
||||||
|
// Under the OLD buggy scheme: same prefix → same hash → COLLISION
|
||||||
|
expect(oldBuggyHash(imgA)).toBe(oldBuggyHash(imgB));
|
||||||
|
|
||||||
|
// Under the FIXED scheme: full data URL hashed → different keys
|
||||||
|
const keyA = makeImageCacheKey(imgA);
|
||||||
|
const keyB = makeImageCacheKey(imgB);
|
||||||
|
expect(keyA).not.toBe(keyB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces same key for identical input", () => {
|
||||||
|
const dataUrl = "data:image/png;base64,samebase64dataheremari";
|
||||||
|
expect(makeImageCacheKey(dataUrl)).toBe(makeImageCacheKey(dataUrl));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefix is always 'image:'", () => {
|
||||||
|
const key = makeImageCacheKey("data:image/png;base64,test");
|
||||||
|
expect(key.startsWith("image:")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// Screen share input resolution tests
|
// Screen share input resolution tests
|
||||||
//
|
//
|
||||||
// Verifies the decision logic of getDirectScreenInput:
|
// getDirectScreenInput now streams the merged video+audio media straight from
|
||||||
// - merged progressive URL → returned directly
|
// yt-dlp stdout (`-o -`) — same auth-handling mechanism as resolveMediaUrl for
|
||||||
// - video+audio DASH pair → local ffmpeg merge (Readable)
|
// music. There is no manual URL fetch or local ffmpeg merge anymore.
|
||||||
// - neither → rejection
|
|
||||||
//
|
//
|
||||||
// Both yt-dlp and ffmpeg are faked via PATH shim scripts so the test does not
|
// yt-dlp is faked via a PATH shim script so the test does not hit the network
|
||||||
// hit the network or need real binaries.
|
// or need real binaries.
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
chmodSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
@@ -25,28 +30,21 @@ const realPath = process.env.PATH;
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
||||||
|
|
||||||
// Fake yt-dlp: prints the JSON file named in GMW_FAKE_YTDLP_JSON.
|
// Fake yt-dlp: streams a few bytes to stdout (like `yt-dlp -o -` does).
|
||||||
// If the file is missing → exits 1 (mimics yt-dlp failure).
|
// Modes (env):
|
||||||
|
// GMW_FAKE_YTDLP_FAIL=1 → stderr 403 + exit 8 WITHOUT stdout bytes
|
||||||
|
// (mimics a download rejected by YouTube).
|
||||||
const ytShim = `#!/usr/bin/env bash
|
const ytShim = `#!/usr/bin/env bash
|
||||||
if [ -n "$GMW_FAKE_YTDLP_JSON" ] && [ -f "$GMW_FAKE_YTDLP_JSON" ]; then
|
if [ "$GMW_FAKE_YTDLP_FAIL" = "1" ]; then
|
||||||
cat "$GMW_FAKE_YTDLP_JSON"
|
echo "ERROR: [youtube] ...: 403 Forbidden (access denied)" >&2
|
||||||
exit 0
|
exit 8
|
||||||
fi
|
fi
|
||||||
echo "yt-dlp: fake JSON missing" >&2
|
# Fake yt-dlp — ignore args, emit a few bytes so consumers see a live stream.
|
||||||
exit 1
|
|
||||||
`;
|
|
||||||
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
|
|
||||||
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
|
|
||||||
|
|
||||||
// Fake ffmpeg: writes a small nut-ish payload to stdout so the returned
|
|
||||||
// Readable actually emits data (the merge path in mergeScreenStreams).
|
|
||||||
const ffShim = `#!/usr/bin/env bash
|
|
||||||
# Fake ffmpeg — ignore args, emit a few bytes so consumers see a live stream.
|
|
||||||
head -c 4096 /dev/urandom
|
head -c 4096 /dev/urandom
|
||||||
exit 0
|
exit 0
|
||||||
`;
|
`;
|
||||||
writeFileSync(join(fakeBinDir, "ffmpeg"), ffShim);
|
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
|
||||||
chmodSync(join(fakeBinDir, "ffmpeg"), 0o755);
|
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
|
||||||
|
|
||||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
||||||
});
|
});
|
||||||
@@ -59,84 +57,76 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||||
function writeFakeJson(payload: Record<string, unknown>): string {
|
function consumeStream(stream: Readable): Promise<string> {
|
||||||
const p = join(
|
return new Promise<string>((resolve) => {
|
||||||
tmpdir(),
|
let got = 0;
|
||||||
`gmw-fake-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
stream.on("data", (chunk: Buffer) => {
|
||||||
);
|
got += chunk.length;
|
||||||
writeFileSync(p, JSON.stringify(payload));
|
});
|
||||||
return p;
|
stream.on("error", () => resolve(`error-after-${got}B`));
|
||||||
}
|
stream.on("end", () => resolve(`end-after-${got}B`));
|
||||||
|
stream.resume();
|
||||||
function dashPairInfo(videoUrl: string, audioUrl: string) {
|
});
|
||||||
return {
|
|
||||||
url: null,
|
|
||||||
acodec: "none", // top-level is not a single merged format
|
|
||||||
vcodec: "av01",
|
|
||||||
requested_formats: [
|
|
||||||
{
|
|
||||||
format_id: "136",
|
|
||||||
vcodec: "avc1.4d401f",
|
|
||||||
acodec: "none",
|
|
||||||
url: videoUrl,
|
|
||||||
},
|
|
||||||
{ format_id: "140", vcodec: "none", acodec: "mp4a.40.2", url: audioUrl },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── tests ─────────────────────────────────────────────────────────────────────
|
// ─── tests ─────────────────────────────────────────────────────────────────────
|
||||||
describe("getDirectScreenInput", () => {
|
describe("getDirectScreenInput", () => {
|
||||||
it("returns the single merged progressive URL when the info has one", async () => {
|
it("returns a live Readable and streams media bytes from yt-dlp stdout", async () => {
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
|
|
||||||
url: "https://cdn.example/progressive.mp4",
|
|
||||||
acodec: "mp4a.40.2",
|
|
||||||
vcodec: "avc1",
|
|
||||||
});
|
|
||||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
|
||||||
expect(result).toBe("https://cdn.example/progressive.mp4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a live Readable when a video+audio DASH pair must be merged", async () => {
|
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(
|
|
||||||
dashPairInfo(
|
|
||||||
"https://cdn.example/video.mp4",
|
|
||||||
"https://cdn.example/audio.m4a",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||||
expect(Readable.isReadable(result)).toBe(true);
|
expect(Readable.isReadable(result)).toBe(true);
|
||||||
|
|
||||||
// The fake ffmpeg emits bytes; collect a chunk to prove the stream flows.
|
const outcome = await consumeStream(result);
|
||||||
const bytes = await new Promise<number>((resolve, reject) => {
|
// The fake yt-dlp emits 4096 bytes → the stream must deliver them.
|
||||||
const stream = result as Readable;
|
expect(outcome).toMatch(/^(error|end)-after-[1-9]\d*B$/);
|
||||||
let got = 0;
|
|
||||||
stream.on("data", (chunk: Buffer) => {
|
|
||||||
got += chunk.length;
|
|
||||||
});
|
|
||||||
stream.on("error", reject);
|
|
||||||
stream.on("end", () => resolve(got));
|
|
||||||
stream.resume();
|
|
||||||
});
|
|
||||||
expect(bytes).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when yt-dlp returns neither a merged URL nor a format pair", async () => {
|
it("destroys the stream with an error when yt-dlp fails before producing data (transient 403)", async () => {
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
|
// Simulate the production failure: yt-dlp's downloader hits a transient
|
||||||
url: null,
|
// YouTube 403 and exits non-zero WITHOUT emitting a single byte. The
|
||||||
acodec: "none",
|
// returned Readable must terminate with zero bytes (error OR end) so the
|
||||||
vcodec: "none",
|
// controller's resolveInputWithRetry retries with a fresh run instead of
|
||||||
requested_formats: [],
|
// streaming a silent black tile.
|
||||||
});
|
process.env.GMW_FAKE_YTDLP_FAIL = "1";
|
||||||
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
try {
|
||||||
/neither a merged progressive URL nor a video\+audio/,
|
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||||
);
|
expect(Readable.isReadable(result)).toBe(true);
|
||||||
|
|
||||||
|
const outcome = await consumeStream(result);
|
||||||
|
expect(outcome).toMatch(/^(error|end)-after-0B$/);
|
||||||
|
} finally {
|
||||||
|
delete process.env.GMW_FAKE_YTDLP_FAIL;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when yt-dlp exits non-zero", async () => {
|
it("passes -o - (stdout streaming) and a temp dir to yt-dlp", async () => {
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = "/nonexistent/gmw-fake.json";
|
const argsDump = join(
|
||||||
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
tmpdir(),
|
||||||
/screen input resolution exited with code 1/,
|
`gmw-ytargs-${process.pid}-${Date.now()}.txt`,
|
||||||
);
|
);
|
||||||
|
process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump;
|
||||||
|
// Augment the fake to dump its argv.
|
||||||
|
const shim = `#!/usr/bin/env bash
|
||||||
|
printf '%s\\n' "$*" >> "$GMW_FAKE_YTDLP_DUMP_ARGS"
|
||||||
|
head -c 4096 /dev/urandom
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
const realPath2 = process.env.PATH;
|
||||||
|
const dir = fakeBinDir as unknown as string;
|
||||||
|
const existing = join(dir, "yt-dlp");
|
||||||
|
// Overwrite with the argv-dumping variant.
|
||||||
|
writeFileSync(existing, shim);
|
||||||
|
chmodSync(existing, 0o755);
|
||||||
|
try {
|
||||||
|
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||||
|
await consumeStream(result);
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
const args = readFileSync(argsDump, "utf8").trim();
|
||||||
|
expect(args).toContain("-o -");
|
||||||
|
expect(args).toMatch(/gmw-ytdlp-/);
|
||||||
|
} finally {
|
||||||
|
delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS;
|
||||||
|
rmSync(argsDump, { force: true });
|
||||||
|
process.env.PATH = realPath2;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Term glossary — pure extraction/formatting tests (no DB, Redis, or network)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
extractGlossaryTerms,
|
||||||
|
formatTermGlossary,
|
||||||
|
} from "../src/modules/ai-moderation/termGlossary.js";
|
||||||
|
|
||||||
|
describe("extractGlossaryTerms — filters out words the LLM already knows", () => {
|
||||||
|
it("returns [] for common conversational Indonesian", () => {
|
||||||
|
const terms = extractGlossaryTerms(
|
||||||
|
["anjay mabar yuk gaskeun gua gas", "iya bener banget sih"],
|
||||||
|
{ maxTerms: 6 },
|
||||||
|
);
|
||||||
|
expect(terms).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts uncommon/foreign-looking words and skips stopwords + brands", () => {
|
||||||
|
const terms = extractGlossaryTerms(
|
||||||
|
[
|
||||||
|
"tadi gua baca soal tempeh di discord",
|
||||||
|
"kayaknya istilahnya shirkmaxxing deh",
|
||||||
|
],
|
||||||
|
{ maxTerms: 6 },
|
||||||
|
);
|
||||||
|
// "tempeh" and "shirkmaxxing" are candidates; "discord"/"istilahnya" are not
|
||||||
|
expect(terms).toContain("tempeh");
|
||||||
|
expect(terms).toContain("shirkmaxxing");
|
||||||
|
expect(terms).not.toContain("discord");
|
||||||
|
expect(terms).not.toContain("istilahnya");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips URLs, mentions, and custom emoji before extracting", () => {
|
||||||
|
const terms = extractGlossaryTerms(
|
||||||
|
["cek https://example.com/foo <@123456> <:hadeh:987> kafircel"],
|
||||||
|
{ maxTerms: 6 },
|
||||||
|
);
|
||||||
|
expect(terms).toContain("kafircel");
|
||||||
|
expect(terms.some((t) => /example|hadeh|123/.test(t))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts quoted phrases as a single term", () => {
|
||||||
|
const terms = extractGlossaryTerms(['dia bilang "kostum hewan" itu aneh'], {
|
||||||
|
maxTerms: 6,
|
||||||
|
});
|
||||||
|
expect(terms).toContain("kostum hewan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips repeated-char noise like wkwkwk and aaaaa", () => {
|
||||||
|
const terms = extractGlossaryTerms(["wkwkwkwk aaaaa xixixi"], {
|
||||||
|
maxTerms: 6,
|
||||||
|
});
|
||||||
|
expect(terms).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects maxTerms and prioritizes proper nouns", () => {
|
||||||
|
const terms = extractGlossaryTerms(
|
||||||
|
["aku suka Xenogears sama Chrono Cross terus Yakuza"],
|
||||||
|
{ maxTerms: 2 },
|
||||||
|
);
|
||||||
|
expect(terms.length).toBeLessThanOrEqual(2);
|
||||||
|
expect(terms[0]).toBe("Xenogears");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTermGlossary — XML block shape", () => {
|
||||||
|
it("returns '' for an empty map", () => {
|
||||||
|
expect(formatTermGlossary(new Map())).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps definitions in <term_glossary> with escaped attributes/content", () => {
|
||||||
|
const block = formatTermGlossary(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"kafircel",
|
||||||
|
{
|
||||||
|
term: "kafircel",
|
||||||
|
definition: "sebutan <memes> untuk & orang",
|
||||||
|
sourceUrl: "https://id.wikipedia.org/wiki/Mem",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(block).toContain("<term_glossary>");
|
||||||
|
expect(block).toContain('<term word="kafircel"');
|
||||||
|
expect(block).toContain("<memes>");
|
||||||
|
expect(block).toContain("&");
|
||||||
|
expect(block).toContain("</term_glossary>");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user