Compare commits

...

21 Commits

Author SHA1 Message Date
MythEclipse
8584030633 feat: automate .env deployment via GitHub Secrets and local file sync 2026-05-17 21:24:54 +07:00
MythEclipse
f35710db3f feat: add Docker configuration and automated deployment workflows for VPS orchestration 2026-05-17 21:23:50 +07:00
MythEclipse
0c7930bd01 chore: update streaming default quality settings to 1080p60 at 8mbps with ultrafast preset 2026-05-17 20:47:41 +07:00
MythEclipse
e22e620bae feat: add streaming demuxer tests and implement dual-stream output piping in streaming service 2026-05-17 20:44:13 +07:00
MythEclipse
eda32720c8 a 2026-05-17 19:39:46 +07:00
MythEclipse
c0f66c78a3 chore: update subproject commit to indicate dirty state 2026-05-17 18:24:17 +07:00
MythEclipse
b8a6f40b1b feat: enhance database initialization for test isolation and add transcoder metrics 2026-05-17 18:24:10 +07:00
MythEclipse
4931e6d1ca test: add unit tests for playTranscodedPreparedStream and transcoder functionality 2026-05-17 17:54:39 +07:00
MythEclipse
a3e6c4695a chore: remove test environment configuration file 2026-05-17 17:35:37 +07:00
MythEclipse
6de5342703 feat: refactor screen share controller to use Streamer for session management and simplify stream handling 2026-05-17 05:15:38 +07:00
MythEclipse
5a926dbd17 refactor: remove Discord-video-stream submodule and integrate streaming functionality 2026-05-17 05:10:46 +07:00
MythEclipse
7985efbef6 docs: add internal streamer replacement design 2026-05-17 05:00:04 +07:00
MythEclipse
71889ab689 chore: update Discord-video-stream subproject to latest commit 2026-05-17 04:52:20 +07:00
MythEclipse
518577d79d feat: enhance screen share controller with Streamer integration and voice channel management 2026-05-17 01:01:40 +07:00
MythEclipse
d04093ec6e feat: add admin authentication to media and voice routes for secure access 2026-05-17 00:39:29 +07:00
MythEclipse
05feb697f0 feat: implement admin authentication overlay and API integration for secure access to voice and media controls 2026-05-17 00:20:29 +07:00
MythEclipse
a5b5ccf5b0 refactor: enhance message ID handling in parseModerationResponse for precision loss and duplicates 2026-05-16 23:47:50 +07:00
MythEclipse
99ec528a03 refactor: remove unused getThreads function and related code from voice API and controller 2026-05-16 23:34:07 +07:00
MythEclipse
7dedac2094 refactor: clean up code structure and improve error handling in media controllers 2026-05-16 23:11:46 +07:00
MythEclipse
9b211f05cf refactor: change logger level from info to debug for voice activity detection 2026-05-16 21:56:14 +07:00
MythEclipse
4825dc6d4d feat: add mock-crc module for ESM compatibility 2026-05-16 21:52:13 +07:00
62 changed files with 1881 additions and 1064 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.env
.env.test
.git
.github
recordings
*.db
*.db-shm
*.db-wal
test_out.nut

View File

@@ -1,30 +0,0 @@
DISCORD_TOKEN=test_token_for_testing
RECORDINGS_DIR=./recordings
RECORDING_SEGMENT_MS=5000
VERBOSE=false
DECODER_ROTATE_MS=5000
DECODER_COOLDOWN_MS=30000
AUDIO_STREAM_SILENCE_DURATION_MS=3000
PACKET_FILTER_MIN_SIZE=8
OPUS_FRAME_SIZE=960
AUDIO_SAMPLE_RATE=48000
AUDIO_CHANNELS=2
AVATAR_SIZE=64
WEBSERVER_PORT=3000
VOICE_CONNECTION_TIMEOUT_MS=15000
RECONNECT_TIMEOUT_MS=5000
LOG_LEVEL=info
NODE_ENV=test
MONITOR_GUILD_ID=test_guild_id
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
ATTACHMENT_MAX_SIZE_MB=100
ATTACHMENT_RETRY_ATTEMPTS=3
BACKLOG_SYNC_HOURS=24
BACKLOG_SYNC_BATCH_SIZE=100
AI_ANALYSIS_ENABLED=false
AI_LLM_API_KEY=test_key
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free
AI_ANALYSIS_TIMEOUT_MS=30000
DATABASE_TYPE=sqlite

35
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Deploy to VPS
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: ENV_FILE
script: |
cd /opt/imphenbot || exit
# Pull latest changes
git pull origin main
# Write environment variables from GitHub Secrets
echo "$ENV_FILE" > .env
# Build and restart containers
docker-compose up -d --build

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist/
public/app/
.muxer-queue.**
.claude/
.env.test

4
.gitmodules vendored
View File

@@ -1,6 +1,4 @@
[submodule "vendor/discord.js-selfbot-v13"]
path = vendor/discord.js-selfbot-v13
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
[submodule "vendor/Discord-video-stream"]
path = vendor/Discord-video-stream
url = ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:22-bookworm-slim
# Install dependencies required by node-canvas, ffmpeg, and yt-dlp
RUN apt-get update && apt-get install -y \
ffmpeg \
python3 \
curl \
git \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install yt-dlp
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp
# Enable pnpm
RUN corepack enable
WORKDIR /app
# Install dependencies first for better caching
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile
# Copy the rest of the application
COPY . .
# Build step if required (e.g. build:web)
RUN pnpm run build:web || true
# Set node environment
ENV NODE_ENV=production
# Start the application
CMD ["pnpm", "run", "start"]

60
debug-screen.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { ChildProcess } from "node:child_process";
import dotenv from "dotenv";
import { createYtDlp } from "./src/media/ytdlp.js";
import { prepareStream } from "./src/streaming/index.js";
dotenv.config();
async function test() {
const ytdlp = createYtDlp();
const url = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"; // Small video
console.log("Getting direct video url...");
const directUrl = await ytdlp.getDirectVideoUrl(url);
console.log("Direct URL:", directUrl);
console.log("Preparing stream...");
const { command, output } = prepareStream(directUrl, {
logLevel: "debug",
customInputOptions: [
"-headers",
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3\r\nConnection: keep-alive\r\n",
],
});
const ffmpeg = command as ChildProcess;
ffmpeg.stderr?.on("data", (data: Buffer) => {
console.log("FFMPEG STDERR:", data.toString());
});
let bytesRead = 0;
output.on("data", (chunk: Buffer) => {
bytesRead += chunk.length;
console.log("Stream bytes:", bytesRead);
if (bytesRead > 1024 * 1024) {
ffmpeg.kill("SIGTERM");
}
});
try {
await new Promise<void>((resolve, reject) => {
ffmpeg.on("exit", (code) => {
if (code === 0 || code === null) {
resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code}`));
});
ffmpeg.on("error", reject);
});
} catch (error: unknown) {
console.error(
"Debug stream failed:",
error instanceof Error ? error.message : String(error),
);
}
process.exit(0);
}
test();

45
deploy.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Configuration for CLI deployment
VPS_HOST="45.127.35.244"
VPS_USER="root"
# Find first available private key in ~/.ssh or use specific one if you want to hardcode
SSH_KEY_PATH=$(find ~/.ssh -name "id_rsa" -o -name "id_ed25519" | head -n 1)
echo "🚀 Starting CLI deployment to $VPS_USER@$VPS_HOST..."
if [ -z "$SSH_KEY_PATH" ]; then
echo "⚠️ No SSH key found in ~/.ssh. Falling back to default SSH behavior."
SSH_CMD="ssh -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -o StrictHostKeyChecking=no'"
else
echo "🔑 Using SSH key: $SSH_KEY_PATH"
SSH_CMD="ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no'"
fi
# Directory on the VPS where the app will be deployed
REMOTE_DIR="/opt/imphenbot"
echo "📦 Syncing files to VPS..."
$SSH_CMD "mkdir -p $REMOTE_DIR"
eval "$RSYNC_CMD ./ $VPS_USER@$VPS_HOST:$REMOTE_DIR"
if [ -f .env ]; then
echo "🔒 Copying local .env to VPS..."
if [ -z "$SSH_KEY_PATH" ]; then
scp -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
else
scp -i $SSH_KEY_PATH -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
fi
else
echo "⚠️ No local .env found to copy."
fi
echo "🔄 Rebuilding and restarting Docker containers..."
$SSH_CMD << EOF
cd $REMOTE_DIR
docker-compose up -d --build
EOF
echo "✅ Deployment complete!"

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
app:
build: .
container_name: imphenbot-app
restart: unless-stopped
env_file:
- .env
volumes:
- ./recordings:/app/recordings
# Mapping SQLite database files if needed, or storing them in a dedicated volume.
# Assuming default config uses root directory for DB.
- ./.muxer-queue.db:/app/.muxer-queue.db
- ./.muxer-queue.db-shm:/app/.muxer-queue.db-shm
- ./.muxer-queue.db-wal:/app/.muxer-queue.db-wal
labels:
- "traefik.enable=true"
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.tech`)"
- "traefik.http.routers.imphenbot.entrypoints=websecure"
- "traefik.http.routers.imphenbot.tls=true"
# Expose port to traefik (adjust if WEBSERVER_PORT differs)
- "traefik.http.services.imphenbot.loadbalancer.server.port=3000"
networks:
- app-shared-net
networks:
app-shared-net:
name: app-shared-net
external: true

View File

@@ -0,0 +1,85 @@
# Internal Streamer Replacement Design
## Summary
Replace the external `@dank074/discord-video-stream` dependency with an internal streaming module that uses `discord.js-selfbot-v13` private APIs to deliver the same screen share behavior (video + audio) with identical UI/API surface.
## Goals
- Maintain feature parity for screen share (video + audio, 720p @ 30fps, bitrate 2500/4000, H264, audio on).
- Keep existing UI and API contracts unchanged (`/api/media/queue` with `mode: "screen"`).
- Remove `@dank074/discord-video-stream` from dependencies and delete `vendor/Discord-video-stream`.
- Ensure clean lifecycle handling (start/stop, cleanup, error reporting).
## Non-Goals
- Rewriting WebRTC/RTP stack from scratch.
- Changing media queue behavior or UI layout.
- Adding new screen share modes or settings.
## Architecture Overview
Introduce a new internal module under `src/streaming/` that encapsulates:
- Voice/session management using private `discord.js-selfbot-v13` APIs.
- FFmpeg preparation for H264 + Opus (AnnexB video + Opus audio).
- Stream playback into the internal dispatcher.
`screenShareController` will depend on this module instead of `@dank074/discord-video-stream`.
## Components
### 1) Streaming Session Module (`src/streaming/`)
Proposed exports:
- `createStreamSession(client)`
- Joins or reuses voice connection for video streaming.
- Exposes a `session` object with `startVideo()`, `stopVideo()`, and `sendStream(stream)` hooks.
- `prepareFfmpegStream(source, opts)`
- Spawns ffmpeg with the same parameters used today.
- Returns `{ command, output }` (output is a Readable stream).
- `playPreparedStream(output, session)`
- Pipes the prepared stream into the internal dispatcher.
- Returns a promise that resolves when playback completes.
### 2) Screen Share Controller (`src/media/screenShareController.ts`)
- Replace Streamer/prepareStream/playStream with internal module usage.
- Keep the public API identical (`start(source)` returning `ScreenSharePlayback`).
### 3) Web Server Wiring (`src/webserver.ts`)
- Remove `Streamer` instantiation and dependencies.
- Pass only `getVoiceStatus` and new streaming module dependencies into `createScreenShareController`.
## Data Flow
1. User queues screen share via `/api/media/queue` with `mode: "screen"`.
2. `MediaController` calls `screenShareController.start(source)`.
3. `screenShareController` resolves URL, calls `prepareFfmpegStream`.
4. `createStreamSession` ensures voice connection and dispatcher ready.
5. `playPreparedStream` sends output to Discord.
6. On completion or stop, cleanup runs and state updates propagate.
## Error Handling
- Voice not connected: throw `VOICE_NOT_CONNECTED`.
- FFmpeg spawn/exit failure: throw `SCREEN_STREAM_FAILED`.
- Dispatcher error: stop stream, cleanup, log error, set state idle.
## Lifecycle Rules
- `start()` always stops any active stream first.
- `stop()` kills ffmpeg, stops dispatcher, and resets internal state.
- Completion resolves `done` promise and triggers cleanup.
## Testing Strategy
- Unit tests for `screenShareController`:
- Calls to `prepareFfmpegStream` and `playPreparedStream` on `start()`.
- Ensures `stop()` kills ffmpeg and ends session.
- Unit tests for `streaming` module:
- Session initialization and cleanup logic with mocked private APIs.
## Migration Steps
1. Implement `src/streaming/` module.
2. Update `screenShareController` to use internal module.
3. Remove `@dank074/discord-video-stream` imports and wiring.
4. Delete `vendor/Discord-video-stream` directory.
5. Update `package.json` dependencies.
6. Update tests.
## Risks
- Private `discord.js-selfbot-v13` APIs may change.
- Harder debugging if internal dispatcher behavior differs.
## Rollback Plan
- Revert to previous commit that restores `@dank074/discord-video-stream` and the vendor directory.

View File

@@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discord Moderation Dashboard</title>
</head>

View File

@@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel";
import { ReviewPanel } from "./components/review/ReviewPanel";
import { Tabs, TabsContent } from "./components/ui/tabs";
import { VoicePanel } from "./components/voice/VoicePanel";
import { AuthOverlay } from "./components/layout/AuthOverlay";
import { useDashboardSocket } from "./hooks/useDashboardSocket";
import { mergeMessages, useMessages } from "./hooks/useMessages";
import { useMediaControl } from "./hooks/useMediaControl";
@@ -26,6 +27,7 @@ export default function App() {
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
const [isListening, setIsListening] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
const audioContextListenRef = useRef<AudioContext | null>(null);
const audioContextTransmitRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
@@ -144,16 +146,22 @@ export default function App() {
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
useEffect(() => {
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
if (selectedVoiceGuild) {
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}
}, [selectedVoiceGuild]);
useEffect(() => {
if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
}, [selectedTextGuild, voice.loadTextTargets]);
if (selectedTextGuild) {
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
}
}, [selectedTextGuild]);
useEffect(() => {
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}, [selectedTextChannel, messages.fetchMessages]);
if (selectedTextChannel) {
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}
}, [selectedTextChannel]);
const toggleListening = useCallback(async () => {
if (isListening) {
@@ -192,34 +200,43 @@ export default function App() {
</div>
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<TabsContent value="voice">
<VoicePanel
guilds={voice.guilds}
channels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={selectedVoiceChannel}
status={voice.voiceStatus}
loading={voice.loading}
activeSpeakers={activeSpeakers}
levels={levels}
isListening={isListening}
isStreaming={isStreaming}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming}
/>
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<VoicePanel
guilds={voice.guilds}
channels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={selectedVoiceChannel}
status={voice.voiceStatus}
loading={voice.loading}
activeSpeakers={activeSpeakers}
levels={levels}
isListening={isListening}
isStreaming={isStreaming}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming}
/>
)}
</TabsContent>
<TabsContent value="media">
<MediaPanel
state={media.mediaState}
loading={media.loading}
onQueueMusic={(source) => media.enqueue(source, "music")}
onStartScreen={(source) => media.enqueue(source, "screen")}
onSkip={media.skip}
onStop={media.stop}
/>
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<MediaPanel
state={media.mediaState}
loading={media.loading}
onQueueMusic={(source) => media.enqueue(source, "music")}
onStartScreen={(source) => media.enqueue(source, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
/>
)}
</TabsContent>
<TabsContent value="messages">
<MessagesPanel

8
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,8 @@
import { request } from "./client";
export async function login(password: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
}

View File

@@ -50,8 +50,12 @@ class ApiError extends Error {
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const password = localStorage.getItem("admin-password");
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(password ? { "X-Admin-Password": password } : {}),
},
...init,
});

View File

@@ -19,3 +19,10 @@ export function skipMedia(): Promise<MediaState> {
export function stopMedia(): Promise<MediaState> {
return request<MediaState>('/api/media/stop', { method: 'POST' });
}
export function setMediaVolume(volume: number): Promise<MediaState> {
return request<MediaState>('/api/media/volume', {
method: 'POST',
body: JSON.stringify({ volume }),
});
}

View File

@@ -13,10 +13,6 @@ export function getTextChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
}
export function getThreads(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/threads`);
}
export function getVoiceStatus(): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/status');
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { login } from "../../api/auth";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
import { Lock } from "lucide-react";
interface AuthOverlayProps {
onAuthenticated: () => void;
}
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
await login(password);
localStorage.setItem("admin-password", password);
onAuthenticated();
} catch (err) {
setError("Invalid password");
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardDescription>Enter the admin password to access Voice and Media controls.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading || !password}>
{loading ? "Authenticating..." : "Unlock Controls"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -11,9 +11,18 @@ interface MediaPanelProps {
onStartScreen: (source: string) => void;
onSkip: () => void;
onStop: () => void;
onVolumeChange: (volume: number) => void;
}
export function MediaPanel({ state, loading, onQueueMusic, onStartScreen, onSkip, onStop }: MediaPanelProps) {
export function MediaPanel({
state,
loading,
onQueueMusic,
onStartScreen,
onSkip,
onStop,
onVolumeChange,
}: MediaPanelProps) {
return (
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
<Tabs defaultValue="music" className="min-w-0">
@@ -22,7 +31,14 @@ export function MediaPanel({ state, loading, onQueueMusic, onStartScreen, onSkip
<TabsTrigger value="screen">Screen Share</TabsTrigger>
</TabsList>
<TabsContent value="music">
<MusicPlayer loading={loading} onQueue={onQueueMusic} onSkip={onSkip} onStop={onStop} />
<MusicPlayer
loading={loading}
volume={state.musicVolume}
onVolumeChange={onVolumeChange}
onQueue={onQueueMusic}
onSkip={onSkip}
onStop={onStop}
/>
</TabsContent>
<TabsContent value="screen">
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />

View File

@@ -1,18 +1,42 @@
import { Music2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
interface MusicPlayerProps {
loading: boolean;
volume: number;
onVolumeChange: (volume: number) => void;
onQueue: (source: string) => void;
onSkip: () => void;
onStop: () => void;
}
export function MusicPlayer({ loading, onQueue, onSkip, onStop }: MusicPlayerProps) {
export function MusicPlayer({
loading,
volume,
onVolumeChange,
onQueue,
onSkip,
onStop,
}: MusicPlayerProps) {
const [source, setSource] = useState("");
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
useEffect(() => {
setDraftVolume(Math.round(safeVolume * 100));
}, [safeVolume]);
useEffect(() => {
const normalized = draftVolume / 100;
if (Math.abs(normalized - safeVolume) < 0.001) return;
const timer = window.setTimeout(() => {
onVolumeChange(normalized);
}, 150);
return () => window.clearTimeout(timer);
}, [draftVolume, onVolumeChange, safeVolume]);
const submit = () => {
const trimmed = source.trim();
@@ -34,6 +58,21 @@ export function MusicPlayer({ loading, onQueue, onSkip, onStop }: MusicPlayerPro
onKeyDown={(event) => event.key === "Enter" && submit()}
placeholder="YouTube URL, Spotify track, or search terms"
/>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Volume</span>
<span className="text-muted-foreground">{draftVolume}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={draftVolume}
onChange={(event) => setDraftVolume(Number(event.target.value))}
className="h-2 w-full cursor-pointer accent-primary"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>

View File

@@ -23,7 +23,7 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex gap-3">
<img
src={message.avatar_url ?? "/default-avatar.png"}
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
alt=""
className="h-10 w-10 rounded-full object-cover"
/>

View File

@@ -1,8 +1,19 @@
import { useCallback, useEffect, useState } from "react";
import { getMediaStatus, queueMedia, skipMedia, stopMedia } from "../api/media";
import {
getMediaStatus,
queueMedia,
setMediaVolume,
skipMedia,
stopMedia,
} from "../api/media";
import type { MediaMode, MediaState } from "../types/media";
const emptyMediaState: MediaState = { playing: false, current: null, queue: [] };
const emptyMediaState: MediaState = {
playing: false,
musicVolume: 1,
current: null,
queue: [],
};
export function useMediaControl() {
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
@@ -55,9 +66,32 @@ export function useMediaControl() {
}
}, []);
const setVolume = useCallback(async (volume: number) => {
setError(null);
try {
const state = await setMediaVolume(volume);
setMediaState(state);
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
}
}, []);
useEffect(() => {
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [refreshMedia]);
return { mediaState, setMediaState, loading, error, refreshMedia, enqueue, skip, stop };
return {
mediaState,
setMediaState,
loading,
error,
refreshMedia,
enqueue,
skip,
stop,
setVolume,
};
}

View File

@@ -18,11 +18,15 @@ export function useMessages() {
const [error, setError] = useState<string | null>(null);
const fetchMessages = useCallback(async (channelId?: string) => {
if (!channelId) {
setMessages([]);
return [];
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ limit: "80" });
if (channelId) params.set("channel", channelId);
params.set("channel", channelId);
const result = await listMessages(params);
setMessages(result.data);
return result.data;

View File

@@ -4,7 +4,6 @@ import {
disconnectVoice,
getGuilds,
getTextChannels,
getThreads,
getVoiceChannels,
getVoiceStatus,
} from "../api/voice";
@@ -46,13 +45,9 @@ export function useVoiceControl() {
setTextChannels([]);
return [];
}
const [channels, threads] = await Promise.all([
getTextChannels(guildId),
getThreads(guildId).catch(() => []),
]);
const combined = [...channels, ...threads];
setTextChannels(combined);
return combined;
const channels = await getTextChannels(guildId);
setTextChannels(channels);
return channels;
}, []);
const joinVoice = useCallback(async (guildId: string, channelId: string) => {

View File

@@ -11,6 +11,7 @@ export interface MediaItem {
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}

View File

@@ -23,7 +23,7 @@
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
},
"dependencies": {
"@dank074/discord-video-stream": "workspace:*",
"@dank074/discord-video-stream": "^6.0.0",
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.1",
"@radix-ui/react-scroll-area": "^1.2.10",

666
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
packages:
- .
- vendor/discord.js-selfbot-v13
- vendor/Discord-video-stream
onlyBuiltDependencies:
- '@discordjs/opus'

View File

@@ -76,6 +76,7 @@ const configSchema = z
POSTGRES_DB: z.string().optional(),
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
ADMIN_PASSWORD: z.string().default("admin123"),
})
.superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) {

View File

@@ -19,11 +19,15 @@ let db:
*/
export async function initializeDatabase() {
if (db !== null) {
logger.debug("Database already initialized, skipping");
return db;
}
if (config.DATABASE_TYPE === "postgres") {
// During tests prefer an isolated SQLite instance to avoid using shared
// external Postgres instances which can lead to flaky test interference.
const usePostgres =
config.DATABASE_TYPE === "postgres" && process.env.NODE_ENV !== "test";
if (usePostgres) {
let pool: Pool;
// Use DATABASE_URL if available, otherwise build from individual variables
@@ -46,12 +50,25 @@ export async function initializeDatabase() {
}
db = drizzlePostgres(pool, { schema });
// Provide a simple `run` helper for tests that expect it.
try {
(db as any).run = (sql: string) => pool.query(sql);
} catch {
// ignore
}
logger.info("PostgreSQL database initialized");
} else {
const sqlite = new Database(".muxer-queue.db");
sqlite.pragma("journal_mode = WAL");
db = drizzleSqlite(sqlite, { schema });
// Expose a convenience `run` method used by tests that expect a simple API.
// `sqlite` is the underlying better-sqlite3 Database instance.
try {
(db as any).run = (sql: string) => sqlite.exec(sql);
} catch {
// ignore
}
logger.info("SQLite database initialized");
}

View File

@@ -1,3 +1,4 @@
import "./mock-crc";
import "libsodium-wrappers";
import "@snazzah/davey";
import "dotenv/config";
@@ -72,6 +73,14 @@ async function initializeApp() {
process.exit(1);
}
client.on("debug", (msg) => {
if (msg.includes("[VOICE") || msg.includes("[ffmpeg") || msg.toLowerCase().includes("error") || msg.toLowerCase().includes("stream")) {
logger.info({ debugMsg: msg }, "Discord Client Debug");
} else if (config.VERBOSE) {
logger.debug({ debugMsg: msg }, "Discord Client Debug");
}
});
client.on("ready", async () => {
logger.info({ user: client.user?.tag }, "Bot logged in");
registerMessageCapture(client);

View File

@@ -17,10 +17,13 @@ import { createMusicPlayer } from "./musicPlayer";
export interface MediaControllerDependencies {
isVoiceConnected?: () => boolean;
isBrowserStreaming?: () => boolean;
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
musicPlayer?: MusicPlayer;
screenController?: ScreenShareController;
onStateChange?: (state: MediaState) => void;
initialMusicVolume?: number;
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
setMusicVolume?: (volume: number) => void;
}
export class MediaController {
@@ -31,9 +34,18 @@ export class MediaController {
private skipInProgress = false;
private screenPlayback: ScreenSharePlayback | null = null;
private activeMode: MediaMode | null = null;
private musicVolume: number;
private readonly setPlayerMusicVolume: (volume: number) => void;
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
this.setPlayerMusicVolume =
dependencies.setMusicVolume ??
((volume) => {
discordPlayer.setMusicVolume(volume);
});
this.musicVolume = normalizeVolume(dependencies.initialMusicVolume, 1);
this.setPlayerMusicVolume(this.musicVolume);
}
getState(): MediaState {
@@ -42,23 +54,52 @@ export class MediaController {
playing:
this.activeMode === "screen" || snapshot.current?.status === "playing",
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
musicVolume: this.musicVolume,
...snapshot,
};
}
async setMusicVolume(volume: number): Promise<MediaState> {
const nextVolume = normalizeVolume(volume, this.musicVolume);
if (this.musicVolume === nextVolume) return this.emitState();
this.musicVolume = nextVolume;
this.setPlayerMusicVolume(nextVolume);
await this.dependencies.onMusicVolumeChange?.(nextVolume);
return this.emitState();
}
async queue(
source: string,
options: QueueMediaOptions = {},
): Promise<MediaState> {
const mode = options.mode ?? "music";
const resolved = await (
this.dependencies.resolveMediaSource ?? resolveMediaSource
)(source, mode);
if (mode === "screen") {
return this.startScreen(source);
// Stop current music if any
this.playbackToken++;
this.playback?.stop();
this.playback = null;
return this.startScreen(resolved.source);
}
// mode === "music"
// If a screen share is active outside of this controller (browser-owned),
// reject to avoid stealing the shared player. If this controller started
// the screenPlayback, stop it and proceed.
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
if (this.dependencies.screenController?.isActive() && !this.screenPlayback) {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
this.screenPlayback?.stop();
this.screenPlayback = null;
this.activeMode = null;
}
this.assertCanStartMusic();
const resolved = await (
this.dependencies.resolveMediaSource ?? resolveMediaSource
)(source);
this.queueStore.add(resolved, mode, options.requestedBy);
this.startNextIfIdle();
return this.emitState();
@@ -108,10 +149,6 @@ export class MediaController {
);
}
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
if (this.dependencies.isBrowserStreaming?.()) {
throw new AppError(
"Stop browser microphone streaming before playing media",
@@ -122,14 +159,6 @@ export class MediaController {
}
private async startScreen(source: string): Promise<MediaState> {
if (
this.screenPlayback ||
this.dependencies.screenController?.isActive() ||
this.playback ||
this.queueStore.snapshot().current
) {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
const screenController = this.dependencies.screenController;
if (!screenController) {
throw new AppError(
@@ -201,3 +230,8 @@ export class MediaController {
return state;
}
}
function normalizeVolume(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.max(0, Math.min(1, value as number));
}

View File

@@ -1,7 +1,7 @@
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import { AppError } from "../errors";
import type { ResolvedMediaSource } from "./mediaTypes";
import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
import { createPlayDlResolver } from "./playDlResolver";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
@@ -18,7 +18,10 @@ export function createMediaResolver(
const ytdlp = dependencies.ytdlp ?? createYtDlp();
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
return async function resolve(input: string): Promise<ResolvedMediaSource> {
return async function resolve(
input: string,
mode: MediaMode = "music"
): Promise<ResolvedMediaSource> {
const source = input.trim();
if (!source) {
throw new AppError(
@@ -31,13 +34,17 @@ export function createMediaResolver(
const url = parseUrl(source);
if (url && isYouTubeUrl(url)) {
const metadata = await ytdlp.getMetadata(source);
const directUrl = await ytdlp.getDirectAudioUrl(source);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(source)
: await ytdlp.getDirectAudioUrl(source);
return { source: directUrl, title: metadata.title, kind: "youtube" };
}
if (url && isSpotifyTrackUrl(url)) {
const result = await playDlResolver.resolveSpotifyTrack(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(result.url)
: await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "spotify" };
}
@@ -55,7 +62,9 @@ export function createMediaResolver(
if (!url && !looksLikeUrl(source)) {
const result = await playDlResolver.searchYouTube(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(result.url)
: await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "search" };
}

View File

@@ -1,4 +1,5 @@
import type { Readable } from "node:stream";
import type { StreamType } from "@discordjs/voice";
export type MediaMode = "music" | "screen";
export type MediaSourceKind =
@@ -26,6 +27,7 @@ export interface MediaQueueItem extends ResolvedMediaSource {
export interface MediaState {
playing: boolean;
activeMode: MediaMode | null;
musicVolume: number;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}
@@ -56,11 +58,23 @@ export interface ScreenShareController {
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
export interface DiscordPlayOptions {
inputType?: StreamType;
inlineVolume?: boolean;
volume?: number;
}
export interface DiscordAudioPlayer {
getOwner(): DiscordPlayerOwner;
isConnected(): boolean;
playStream(stream: Readable, owner: DiscordPlayerOwner): void;
playStream(
stream: Readable,
owner: DiscordPlayerOwner,
options?: DiscordPlayOptions,
): void;
pause(owner?: DiscordPlayerOwner): void;
unpause(owner?: DiscordPlayerOwner): boolean;
stop(owner?: DiscordPlayerOwner): void;
getMusicVolume(): number;
setMusicVolume(volume: number): void;
}

View File

@@ -1,5 +1,6 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import { StreamType } from "@discordjs/voice";
import { discordPlayer } from "../player";
import type {
DiscordAudioPlayer,
@@ -30,7 +31,10 @@ export function createMusicPlayer(
}) as unknown as ChildProcessWithoutNullStreams;
proc.stderr.resume();
audioPlayer.playStream(proc.stdout, "music");
audioPlayer.playStream(proc.stdout, "music", {
inputType: StreamType.Raw,
inlineVolume: true,
});
let stopped = false;
let released = false;
@@ -81,13 +85,13 @@ export function buildFfmpegArgs(source: string): string[] {
source,
"-vn",
"-acodec",
"libopus",
"pcm_s16le",
"-ar",
"48000",
"-ac",
"2",
"-f",
"ogg",
"s16le",
"pipe:1",
];
}

View File

@@ -1,12 +1,13 @@
import type { Readable } from "node:stream";
import {
playStream as defaultPlayStream,
prepareStream as defaultPrepareStream,
Encoders,
Utils,
} from "@dank074/discord-video-stream";
Streamer,
playPreparedStream,
} from "../streaming";
import { AppError } from "../errors";
import { createChildLogger } from "../logger";
import { discordPlayer } from "../player";
const logger = createChildLogger("screen-share");
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
import { createYtDlp } from "./ytdlp";
@@ -16,29 +17,16 @@ export interface ScreenShareVoiceStatus {
activeChannelId: string | null;
}
interface PreparedScreenStream {
command: { kill?: (signal: NodeJS.Signals) => unknown };
output: Readable;
}
type PrepareScreenStream = (
source: string,
options: object,
) => PreparedScreenStream;
type PlayScreenStream = (
output: Readable,
streamer: unknown,
options: { type: "go-live" },
) => Promise<void>;
export interface ScreenShareControllerDependencies {
getVoiceStatus: () => ScreenShareVoiceStatus;
getPlayerOwner?: () => DiscordPlayerOwner;
getDirectVideoUrl?: (source: string) => Promise<string>;
prepareStream?: PrepareScreenStream;
playStream?: PlayScreenStream;
streamer: unknown;
streamer: Streamer;
useTranscoder?: boolean;
onBeforeStreamStart?: (guildId: string, channelId: string) => Promise<void> | void;
onAfterStreamEnd?: (guildId: string, channelId: string) => Promise<void> | void;
onStreamStart?: () => void;
onStreamEnd?: () => void;
}
export function createScreenShareController(
@@ -51,12 +39,6 @@ export function createScreenShareController(
const getDirectVideoUrl =
dependencies.getDirectVideoUrl ??
((source) => ytdlp.getDirectVideoUrl(source));
const prepareStream =
dependencies.prepareStream ??
(defaultPrepareStream as unknown as PrepareScreenStream);
const playStream =
dependencies.playStream ??
(defaultPlayStream as unknown as PlayScreenStream);
return {
isActive(): boolean {
@@ -65,6 +47,20 @@ export function createScreenShareController(
async start(source: string): Promise<ScreenSharePlayback> {
const status = dependencies.getVoiceStatus();
let voiceReleased = false;
let voiceRestored = false;
const restoreVoice = async () => {
if (voiceRestored || !voiceReleased || !guildId || !channelId) return;
voiceRestored = true;
await dependencies.onAfterStreamEnd?.(guildId, channelId);
};
if (active) {
active.stop();
}
// Ensure bot is in the voice channel and owns the screen-share stream
if (
!status.connected ||
!status.activeGuildId ||
@@ -77,41 +73,81 @@ export function createScreenShareController(
);
}
if (active || getPlayerOwner() !== "none") {
const guildId = status.activeGuildId;
const channelId = status.activeChannelId;
// If another media owner (e.g. music) holds the shared player, reject
const owner = getPlayerOwner();
if (owner === "music") {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
try {
const directUrl = await getDirectVideoUrl(source);
const { command, output } = prepareStream(directUrl, {
encoder: Encoders.software({ x264: { preset: "superfast" } }),
height: 720,
frameRate: 30,
bitrateVideo: 2500,
bitrateVideoMax: 4000,
includeAudio: true,
videoCodec: Utils.normalizeVideoCodec("H264"),
});
logger.info(
{
guildId,
channelId,
},
"Creating screen share session",
);
await dependencies.onBeforeStreamStart?.(guildId, channelId);
voiceReleased = true;
const session = await dependencies.streamer.createSession(
guildId,
channelId,
);
dependencies.onStreamStart?.();
let stopped = false;
const done = playStream(output, dependencies.streamer, {
type: "go-live",
const playFn = dependencies.useTranscoder
? (await import("../streaming")).playTranscodedPreparedStream
: (await import("../streaming")).playPreparedStream;
const done = playFn(directUrl, session, {
fps: 30,
bitrate: 2500,
includeAudio: true,
presetH26x: "superfast",
}).finally(() => {
active = null;
dependencies.onStreamEnd?.();
return restoreVoice();
});
done.catch(() => undefined);
logger.info(
{
guildId,
channelId,
},
"Screen share session started",
);
active = {
done,
stop() {
if (stopped) return;
stopped = true;
command.kill?.("SIGTERM");
session.stop();
active = null;
void restoreVoice();
},
};
return active;
} catch (error) {
active = null;
if (voiceReleased) {
await restoreVoice();
}
logger.error(
{
error,
guildId,
channelId,
},
"Screen share startup failed",
);
throw new AppError(
error instanceof Error ? error.message : "Screen stream failed",
"SCREEN_STREAM_FAILED",

View File

@@ -56,12 +56,12 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
url,
"--get-url",
"--format",
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
"best[protocol^=http]/best",
"--no-playlist",
"--no-warnings",
"--quiet",
]);
return value.trim().split("\n")[0] || url;
return value.trim();
},
};
}

View File

@@ -53,6 +53,17 @@ export const wsMessagesCounter = new Counter({
labelNames: ["message_type"],
});
// Transcoder metrics
export const transcoderRestartsCounter = new Counter({
name: "transcoder_restarts_total",
help: "Total number of transcoder restarts",
});
export const transcoderRunningGauge = new Gauge({
name: "transcoder_running",
help: "Whether a transcoder process is currently running (1/0)",
});
// HTTP metrics
export const httpRequestDurationHistogram = new Histogram({
name: "http_request_duration_seconds",

View File

@@ -1,3 +1,7 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// Mock node-crc to provide pure JS implementation and bypass native build issues
const CRC_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
@@ -39,4 +43,6 @@ Module.prototype.require = function (id: string) {
return originalRequire.apply(this, arguments);
};
console.log("[mock] node-crc has been mocked globally.");
console.log("[mock] node-crc has been mocked globally for ESM.");
export {};

View File

@@ -35,10 +35,21 @@ async function processAnalysisRequest({
messages,
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
try {
if (!dbInitialized) {
await initializeDatabase();
dbInitialized = true;
try {
if (!dbInitialized) {
await initializeDatabase();
dbInitialized = true;
}
} catch (dbError) {
const msg = dbError instanceof Error ? dbError.message : String(dbError);
return {
ok: false,
conversationKey,
rows: [],
error: `Database init failed: ${msg}`,
};
}
const firstMessage = messages[0];
if (!firstMessage) return { ok: true, conversationKey, rows: [] };

View File

@@ -26,35 +26,43 @@ export function parseModerationResponse(
content: string,
targetIds: string[],
): AnalysisResult[] {
// Find first opening brace
// Find first opening brace and last closing brace
const startIdx = content.indexOf("{");
if (startIdx === -1) {
const endIdx = content.lastIndexOf("}");
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
throw new Error("No JSON object found in response");
}
// Scan from start and try parsing at each closing brace
// Attempt to parse the largest possible JSON object
let parsed: unknown;
let lastError: Error | null = null;
const candidate = content.substring(startIdx, endIdx + 1);
for (let i = startIdx + 1; i < content.length; i++) {
if (content[i] === "}") {
const candidate = content.substring(startIdx, i + 1);
try {
parsed = JSON.parse(candidate);
// Successfully parsed, break out
break;
} catch (error) {
// Store error and continue scanning
lastError = error instanceof Error ? error : new Error(String(error));
continue;
try {
parsed = JSON.parse(candidate);
} catch (error) {
// If full substring fails, try scanning backwards from the last }
let lastError: Error =
error instanceof Error ? error : new Error(String(error));
for (let i = endIdx - 1; i > startIdx; i--) {
if (content[i] === "}") {
try {
parsed = JSON.parse(content.substring(startIdx, i + 1));
break;
} catch (innerError) {
lastError =
innerError instanceof Error
? innerError
: new Error(String(innerError));
continue;
}
}
}
}
if (!parsed) {
throw new Error(
`Failed to parse JSON: ${lastError?.message || "No valid JSON object found"}`,
);
if (!parsed) {
throw new Error(`Failed to parse JSON: ${lastError.message}`);
}
}
// Validate structure
@@ -72,7 +80,7 @@ export function parseModerationResponse(
const targetIdSet = new Set(targetIds);
// Parse and validate each result
const results: AnalysisResult[] = response.results.map((result) => {
const results: (AnalysisResult | null)[] = response.results.map((result) => {
const { message_id, status, flags, score, analysis } = result;
// Validate message_id exists and is in target list
@@ -80,15 +88,36 @@ export function parseModerationResponse(
throw new Error("Result missing 'message_id'");
}
if (!targetIdSet.has(message_id)) {
throw new Error(`Unknown message_id: ${message_id}`);
let finalId = String(message_id);
// Precision loss fix: If the ID from LLM is not found,
// try to find the closest match in targets if it looks rounded (ends in 000)
if (!targetIdSet.has(finalId)) {
if (finalId.endsWith("00") || finalId.includes("e+")) {
const roundedPrefix = finalId.substring(0, 10);
const match = targetIds.find((id) => id.startsWith(roundedPrefix));
if (match) {
log.warn(
{ roundedId: finalId, matchedId: match },
"Fixed precision loss in message ID",
);
finalId = match;
}
}
}
if (foundIds.has(message_id)) {
throw new Error(`Duplicate message_id in results: ${message_id}`);
if (!targetIdSet.has(finalId)) {
throw new Error(
`Unknown message_id: ${finalId} (original: ${message_id})`,
);
}
foundIds.add(message_id);
if (foundIds.has(finalId)) {
log.warn({ duplicateId: finalId }, "Duplicate message_id in response");
throw new Error(`Duplicate message_id: ${finalId}`);
}
foundIds.add(finalId);
// Validate status
const validStatuses = ["clean", "warn", "flagged"] as const;
@@ -120,7 +149,7 @@ export function parseModerationResponse(
const analysisStr = analysis ? String(analysis) : "";
return {
messageId: message_id,
messageId: finalId,
status: status as "clean" | "warn" | "flagged",
flags: flagsArray,
score: numScore,
@@ -128,13 +157,18 @@ export function parseModerationResponse(
};
});
const filteredResults = results.filter(
(r): r is AnalysisResult => r !== null,
);
// Check that all target IDs were found
const missingIds = targetIds.filter((id) => !foundIds.has(id));
if (missingIds.length > 0) {
throw new Error(`Missing target ids in response: ${missingIds.join(", ")}`);
log.warn({ missingIds }, "Some target IDs missing in response");
throw new Error(`Missing target IDs: ${missingIds.join(",")}`);
}
return results;
return filteredResults;
}
interface ModerationInput {
@@ -174,8 +208,11 @@ Context: ${contextText}
Messages to analyze:
${messagesText}
For each message, respond with a JSON object containing a "results" array. Each result must have:
- message_id: the message ID
For each message, respond with a JSON object containing a "results" array.
CRITICAL: You MUST return the "message_id" EXACTLY as provided in the input, and it MUST be wrapped in double quotes as a STRING. Do not treat IDs as numbers.
Each result must have:
- message_id: the message ID (STRING, exactly as provided)
- status: "clean", "warn", or "flagged"
- flags: array of violation flags (e.g., ["spam", "hate_speech"])
- score: confidence score from 0 to 1
@@ -213,13 +250,44 @@ Return ONLY valid JSON, no other text.`;
}),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(`LLM API error ${response.status}: ${text}`);
// Read the response body once (either text() or json()), then reuse it.
let rawBody: string | undefined = undefined;
if (typeof response.text === "function") {
try {
rawBody = await response.text();
} catch {
rawBody = undefined;
}
} else if (typeof response.json === "function") {
try {
const j = await response.json();
rawBody = JSON.stringify(j);
} catch {
rawBody = undefined;
}
}
return response.json();
if (!response.ok) {
throw new Error(
`LLM API error ${response.status}: ${rawBody ?? "(no body)"}`,
);
}
if (!rawBody) {
throw new Error("Empty LLM response");
}
// Try to parse the body as JSON, with fallback to scanning for an object
try {
return JSON.parse(rawBody);
} catch (e) {
const start = rawBody.indexOf("{");
const end = rawBody.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(rawBody.substring(start, end + 1));
}
throw e;
}
} finally {
clearTimeout(timeoutId);
}

View File

@@ -114,6 +114,7 @@ export interface MediaQueueItem {
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}

View File

@@ -2,17 +2,23 @@ import { Readable } from "node:stream";
import {
AudioPlayer,
AudioPlayerStatus,
type AudioResource,
createAudioPlayer,
createAudioResource,
StreamType,
VoiceConnection,
} from "@discordjs/voice";
import type { DiscordPlayerOwner } from "./media/mediaTypes";
import type {
DiscordPlayOptions,
DiscordPlayerOwner,
} from "./media/mediaTypes";
export class DiscordPlayer {
private player: AudioPlayer;
private connection: VoiceConnection | null = null;
private owner: DiscordPlayerOwner = "none";
private resource: AudioResource | null = null;
private musicVolume = 1;
constructor() {
this.player = createAudioPlayer();
@@ -24,6 +30,7 @@ export class DiscordPlayer {
this.player.on("error", (error) => {
console.error(`[player] Error: ${error.message}`);
this.owner = "none";
this.resource = null;
});
}
@@ -40,20 +47,34 @@ export class DiscordPlayer {
return this.connection !== null;
}
public playStream(stream: Readable, owner: DiscordPlayerOwner) {
public playStream(
stream: Readable,
owner: DiscordPlayerOwner,
options: DiscordPlayOptions = {},
) {
if (owner === "none") {
throw new Error("Discord audio player owner is required");
}
this.assertOwnerAvailable(owner);
const resource = createAudioResource(stream, {
inputType: StreamType.OggOpus,
inputType: options.inputType ?? StreamType.OggOpus,
inlineVolume: options.inlineVolume ?? false,
});
if (this.owner === owner) {
this.player.stop();
}
this.resource = resource;
this.owner = owner;
if (owner === "music") {
const nextVolume =
options.volume !== undefined
? this.normalizeVolume(options.volume)
: this.musicVolume;
this.musicVolume = nextVolume;
this.setResourceVolume(nextVolume);
}
this.player.play(resource);
this.connection?.subscribe(this.player);
}
@@ -76,6 +97,19 @@ export class DiscordPlayer {
if (!this.canControl(owner)) return;
this.player.stop();
this.owner = "none";
this.resource = null;
}
public getMusicVolume(): number {
return this.musicVolume;
}
public setMusicVolume(volume: number): void {
const nextVolume = this.normalizeVolume(volume);
this.musicVolume = nextVolume;
if (this.owner === "music") {
this.setResourceVolume(nextVolume);
}
}
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
@@ -87,6 +121,16 @@ export class DiscordPlayer {
private canControl(owner?: DiscordPlayerOwner): boolean {
return !owner || this.owner === "none" || this.owner === owner;
}
private normalizeVolume(volume: number): number {
if (!Number.isFinite(volume)) return this.musicVolume;
return Math.max(0, Math.min(1, volume));
}
private setResourceVolume(volume: number): void {
if (!this.resource?.volume) return;
this.resource.volume.setVolume(volume);
}
}
export const discordPlayer = new DiscordPlayer();

View File

@@ -125,7 +125,7 @@ export async function startRecording(
const userMetadata = await collectUserMetadata(client, userId, channel);
if (userMetadata.bot) return;
logger.info(
logger.debug(
{ userId, username: userMetadata.username },
"Voice activity detected",
);

View File

@@ -1,4 +1,4 @@
import type { Router } from "express";
import type { NextFunction, Request, Response, Router } from "express";
import express from "express";
import { AppError } from "../errors";
import type { MediaController } from "../media/mediaController";
@@ -6,57 +6,113 @@ import type { MediaMode } from "../media/mediaTypes";
export type MediaRouteController = Pick<
MediaController,
"getState" | "queue" | "skip" | "stop"
"getState" | "queue" | "skip" | "stop" | "setMusicVolume"
>;
export function createMediaRoutes(controller: MediaRouteController): Router {
export interface MediaRouteOptions {
adminPassword?: string;
}
export function createMediaRoutes(
controller: MediaRouteController,
options: MediaRouteOptions = {},
): Router {
const router = express.Router();
const { adminPassword } = options;
router.get("/media/status", (_req, res, next) => {
try {
res.json(controller.getState());
} catch (error) {
next(error);
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
if (!adminPassword) return next();
const authHeader = req.headers["x-admin-password"];
if (authHeader === adminPassword) {
next();
} else {
res.status(401).json({ error: "Unauthorized access to admin features" });
}
});
};
router.post("/media/queue", async (req, res, next) => {
try {
const { source, mode = "music" } = req.body as {
source?: string;
mode?: MediaMode;
};
if (!source) {
throw new AppError(
"Media source is required",
"MISSING_MEDIA_SOURCE",
400,
);
// Apply admin auth as router-level middleware so route stack ordering
// remains predictable for tests that inspect route handlers.
router.use(adminAuth);
router.get(
"/media/status",
(_req: Request, res: Response, next: NextFunction) => {
try {
res.json(controller.getState());
} catch (error) {
next(error);
}
if (mode !== "music" && mode !== "screen") {
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
},
);
router.post(
"/media/queue",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { source, mode = "music" } = req.body as {
source?: string;
mode?: MediaMode;
};
if (!source) {
throw new AppError(
"Media source is required",
"MISSING_MEDIA_SOURCE",
400,
);
}
if (mode !== "music" && mode !== "screen") {
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
}
res.json(await controller.queue(source, { mode }));
} catch (error) {
next(error);
}
res.json(await controller.queue(source, { mode }));
} catch (error) {
next(error);
}
});
},
);
router.post("/media/skip", async (_req, res, next) => {
try {
res.json(await controller.skip());
} catch (error) {
next(error);
}
});
router.post(
"/media/skip",
async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(await controller.skip());
} catch (error) {
next(error);
}
},
);
router.post("/media/stop", async (_req, res, next) => {
try {
res.json(await controller.stop());
} catch (error) {
next(error);
}
});
router.post(
"/media/stop",
async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(await controller.stop());
} catch (error) {
next(error);
}
},
);
router.post(
"/media/volume",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { volume } = req.body as { volume?: number };
if (typeof volume !== "number" || Number.isNaN(volume)) {
throw new AppError("Volume is required", "INVALID_VOLUME", 400);
}
if (volume < 0 || volume > 1) {
throw new AppError(
"Volume must be between 0 and 1",
"INVALID_VOLUME",
400,
);
}
res.json(await controller.setMusicVolume(volume));
} catch (error) {
next(error);
}
},
);
return router;
}

View File

@@ -1,4 +1,4 @@
import type { Router } from "express";
import type { NextFunction, Request, Response, Router } from "express";
import express from "express";
import { AppError } from "../errors";
import { createChildLogger } from "../logger";
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
voiceController: VoiceController;
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
broadcaster: ModerationBroadcaster;
adminPassword?: string;
}
export function createVoiceRoutes(
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
| ((patch: Partial<SharedUIState>) => SharedUIState)
| undefined;
let broadcaster: ModerationBroadcaster | undefined;
let adminPassword: string | undefined;
if ("connect" in options && "disconnect" in options) {
// Old signature: just VoiceController
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
voiceController = opts.voiceController;
patchSharedUIState = opts.patchSharedUIState;
broadcaster = opts.broadcaster;
adminPassword = opts.adminPassword;
}
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
if (!adminPassword) return next();
const authHeader = req.headers["x-admin-password"];
if (authHeader === adminPassword) {
next();
} else {
res.status(401).json({ error: "Unauthorized access to admin features" });
}
};
// GET /api/status - Get voice connection status
router.get("/status", (_req, res, next) => {
router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
try {
const status = voiceController.getStatus();
res.json(status);
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
});
// GET /api/guilds - List available guilds
router.get("/guilds", (_req, res, next) => {
router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
try {
const guilds = voiceController.listGuilds();
res.json(guilds);
@@ -58,109 +71,111 @@ export function createVoiceRoutes(
});
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
router.get("/guilds/:guildId/voice-channels", async (req, res, next) => {
try {
const { guildId } = req.params;
router.get(
"/guilds/:guildId/voice-channels",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { guildId } = req.params;
if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
}
const channels = await voiceController.listVoiceChannels(
guildId as string,
);
res.json(channels);
} catch (error) {
next(error);
}
const channels = await voiceController.listVoiceChannels(guildId);
res.json(channels);
} catch (error) {
next(error);
}
});
},
);
// GET /api/guilds/:guildId/channels - List text channels in a guild
router.get("/guilds/:guildId/channels", async (req, res, next) => {
try {
const { guildId } = req.params;
router.get(
"/guilds/:guildId/channels",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { guildId } = req.params;
if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
}
const channels = await voiceController.listWatchableChannels(
guildId as string,
);
res.json(channels);
} catch (error) {
next(error);
}
const channels = await voiceController.listWatchableChannels(guildId);
res.json(channels);
} catch (error) {
next(error);
}
});
// GET /api/guilds/:guildId/threads - List threads in a guild
router.get("/guilds/:guildId/threads", async (req, res, next) => {
try {
const { guildId } = req.params;
if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
}
const threads = await voiceController.listThreads(guildId);
res.json(threads);
} catch (error) {
next(error);
}
});
},
);
// POST /api/connect - Connect to a voice channel
router.post("/connect", async (req, res, next) => {
try {
const { guildId, channelId } = req.body as {
guildId?: string;
channelId?: string;
};
router.post(
"/connect",
adminAuth,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { guildId, channelId } = req.body as {
guildId?: string;
channelId?: string;
};
if (!guildId || !channelId) {
throw new AppError(
"guildId and channelId are required",
"MISSING_CONNECT_FIELDS",
400,
);
if (!guildId || !channelId) {
throw new AppError(
"guildId and channelId are required",
"MISSING_CONNECT_FIELDS",
400,
);
}
logger.info({ guildId, channelId }, "Connecting to voice channel");
const status = await voiceController.connect(guildId, channelId);
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,
});
broadcaster.uiState(updatedState);
}
res.json(status);
} catch (error) {
next(error);
}
logger.info({ guildId, channelId }, "Connecting to voice channel");
const status = await voiceController.connect(guildId, channelId);
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,
});
broadcaster.uiState(updatedState);
}
res.json(status);
} catch (error) {
next(error);
}
});
},
);
// POST /api/disconnect - Disconnect from voice channel
router.post("/disconnect", async (_req, res, next) => {
try {
logger.info("Disconnecting from voice channel");
router.post(
"/disconnect",
adminAuth,
async (_req: Request, res: Response, next: NextFunction) => {
try {
logger.info("Disconnecting from voice channel");
const status = await voiceController.disconnect();
const status = await voiceController.disconnect();
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedVoiceGuild: "",
selectedVoiceChannel: "",
});
broadcaster.uiState(updatedState);
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedVoiceGuild: "",
selectedVoiceChannel: "",
});
broadcaster.uiState(updatedState);
}
res.json(status);
} catch (error) {
next(error);
}
res.json(status);
} catch (error) {
next(error);
}
});
},
);
return router;
}

174
src/streaming/index.ts Normal file
View File

@@ -0,0 +1,174 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { Readable } from "node:stream";
import type { ChildProcess } from "node:child_process";
import type { Client } from "discord.js-selfbot-v13";
import {
Streamer as DankStreamer,
prepareStream as dankPrepareStream,
playStream as dankPlayStream,
Utils,
Encoders,
} from "@dank074/discord-video-stream";
type VoiceConnectionLike = any;
type StreamConnectionLike = any;
export interface StreamPlayOptions {
fps?: number;
bitrate?: number | string;
includeAudio?: boolean;
presetH26x?: string;
}
export interface StreamSession {
connection: VoiceConnectionLike;
stream: StreamConnectionLike;
play(source: string | Readable, options?: StreamPlayOptions): Promise<void>;
stop(): void;
}
export const UtilsAPI = {
normalizeVideoCodec: (c: string) => c.toUpperCase?.() ?? c,
};
export class Streamer {
client: Client;
dankStreamer: DankStreamer;
constructor(client: Client) {
this.client = client;
this.dankStreamer = new DankStreamer(client);
}
async createSession(guildId: string, channelId: string): Promise<StreamSession> {
await this.dankStreamer.joinVoice(guildId, channelId);
let stopped = false;
let currentCommand: any = null;
const stop = () => {
if (stopped) return;
stopped = true;
try {
if (currentCommand?.kill) currentCommand.kill("SIGKILL");
} catch (e) {
// ignore
}
this.dankStreamer.stopStream();
this.dankStreamer.leaveVoice();
};
return {
connection: {} as any,
stream: {} as any,
play: async (source: string | Readable, options: StreamPlayOptions = {}) => {
if (stopped) return;
let targetSource: string | Readable = source;
if (typeof source === "string" && source.includes("\n")) {
const urls = source.split("\n").filter((u) => u.trim());
targetSource = urls[0] ?? source;
}
const fps = options.fps ?? 60;
const bitrateStr = String(options.bitrate ?? 8000).replace(/k$/i, "");
const bitrateVideo = parseInt(bitrateStr, 10) || 8000;
console.log("[Streamer] Starting screen share for source:", typeof targetSource === "string" ? targetSource.slice(0, 50) + "..." : "ReadableStream");
const { command, output } = dankPrepareStream(targetSource, {
encoder: Encoders.software({
x264: { preset: (options.presetH26x as any) ?? "ultrafast" },
x265: { preset: (options.presetH26x as any) ?? "ultrafast" },
}),
videoCodec: Utils.normalizeVideoCodec("H264"),
width: 1920,
height: 1080,
bitrateVideo: bitrateVideo,
frameRate: fps,
includeAudio: options.includeAudio !== false,
minimizeLatency: false,
customInputOptions: ["-fflags nobuffer"],
customHeaders: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3",
Connection: "keep-alive",
},
});
currentCommand = command;
const webOutput = new PassThrough();
const discordOutput = new PassThrough();
output.pipe(webOutput);
output.pipe(discordOutput);
const globalAny: any = globalThis;
const onData = (chunk: Buffer) => {
try {
globalAny.broadcastVideoToWeb?.(chunk);
} catch {
// ignore
}
};
webOutput.on("data", onData);
command.on("error", (err: Error) => {
console.error("[Streamer] Transcoder error:", err);
});
command.on("stderr", (stderrLine: string) => {
console.error("[Streamer] FFMPEG:", stderrLine);
});
command.on("end", () => {
console.log("[Streamer] FFMPEG process ended naturally.");
});
try {
console.log("[Streamer] Calling dankPlayStream...");
await dankPlayStream(discordOutput, this.dankStreamer, undefined);
console.log("[Streamer] dankPlayStream completed successfully.");
} catch (err) {
console.error("[Streamer] dankPlayStream error:", err);
} finally {
console.log("[Streamer] Cleaning up stream resources.");
webOutput.off("data", onData);
stop();
}
},
stop,
};
}
}
export function prepareStream(source: string, _options: any): any {
return { command: null, output: new PassThrough() };
}
export async function playStream(): Promise<void> {
return;
}
export async function createStreamSession(
client: Client,
guildId: string,
channelId: string,
): Promise<StreamSession> {
return new Streamer(client).createSession(guildId, channelId);
}
export async function playPreparedStream(
source: string | Readable,
session: StreamSession,
options: StreamPlayOptions = {},
): Promise<void> {
await session.play(source, options);
}
export async function playTranscodedPreparedStream(
source: string | Readable,
session: StreamSession,
options: StreamPlayOptions = {},
): Promise<void> {
await session.play(source, options);
}

158
src/streaming/transcoder.ts Normal file
View File

@@ -0,0 +1,158 @@
import { spawn, ChildProcess } from "node:child_process";
import { PassThrough } from "node:stream";
import type { Readable } from "node:stream";
import { retryWithBackoff } from "../retry";
import { createChildLogger } from "../logger";
import { transcoderRestartsCounter, transcoderRunningGauge } from "../metrics";
const logger = createChildLogger("transcoder");
export interface TranscoderOptions {
fps?: number;
bitrate?: string | number;
preset?: string;
}
export class Transcoder {
proc: ChildProcess | null = null;
output: Readable | null = null;
stopping = false;
restartAttempts = 0;
restartTimer: NodeJS.Timeout | null = null;
maxRestarts = 6;
constructor(private source: string, private opts: TranscoderOptions = {}) {}
start(): { command: ChildProcess; output: Readable } {
const fps = this.opts.fps ?? 30;
const bitrate = String(this.opts.bitrate ?? "2500k");
const preset = this.opts.preset ?? "superfast";
const args = [
"-hide_banner",
"-loglevel",
"warning",
"-i",
this.source,
"-c:v",
"libx264",
"-preset",
preset,
"-r",
String(fps),
"-s",
"1280x720",
"-b:v",
String(bitrate),
"-maxrate",
"4000k",
"-c:a",
"libopus",
"-f",
"matroska",
"-",
];
const cmd = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
const out = cmd.stdout ?? new PassThrough();
this.proc = cmd;
this.output = out;
cmd.on("error", (err) => {
logger.error({ err }, "transcoder process error");
});
cmd.on("exit", (code, signal) => {
logger.info({ code, signal }, "transcoder exited");
transcoderRunningGauge.set(0);
// If we didn't explicitly stop, attempt restart with backoff
if (!this.stopping) {
this.scheduleRestart();
}
});
transcoderRunningGauge.set(1);
return { command: cmd, output: out };
}
stop(): void {
this.stopping = true;
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
try {
if (this.proc && !this.proc.killed) this.proc.kill("SIGTERM");
} catch (e) {
logger.warn({ e }, "failed to terminate transcoder gracefully");
try {
if (this.proc && !this.proc.killed) this.proc.kill("SIGKILL");
} catch (e2) {
logger.warn({ e2 }, "failed to kill transcoder forcefully");
}
}
this.proc = null;
this.output = null;
transcoderRunningGauge.set(0);
}
scheduleRestart() {
if (this.restartAttempts >= this.maxRestarts) {
logger.error({ attempts: this.restartAttempts }, "transcoder reached max restart attempts");
return;
}
const delay = Math.min(30000, 1000 * Math.pow(2, this.restartAttempts));
this.restartAttempts += 1;
transcoderRestartsCounter.inc();
logger.info({ delay, attempt: this.restartAttempts }, "scheduling transcoder restart");
this.restartTimer = setTimeout(() => {
try {
this.start();
} catch (err) {
logger.error({ err }, "transcoder restart failed");
this.scheduleRestart();
}
}, delay) as unknown as NodeJS.Timeout;
}
async startWithRetry(retries = 2) {
return retryWithBackoff(() => Promise.resolve(this.start()), {
retries,
logger,
});
}
async shutdown(): Promise<void> {
this.stopping = true;
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
if (this.proc && !this.proc.killed) {
return new Promise<void>((resolve) => {
this.proc?.once("exit", () => resolve());
try {
this.proc?.kill("SIGTERM");
} catch {
try {
this.proc?.kill("SIGKILL");
} catch {
resolve();
}
}
setTimeout(() => resolve(), 5000);
}).then(() => {
this.proc = null;
this.output = null;
transcoderRunningGauge.set(0);
});
}
}
}
export function prepareTranscoder(source: string, options: TranscoderOptions = {}) {
const t = new Transcoder(source, options);
const { command, output } = t.start();
return { transcoder: t, command, output };
}

View File

@@ -83,46 +83,6 @@ export class VoiceController {
.sort((a, b) => a.name.localeCompare(b.name));
}
async listThreads(guildId: string): Promise<ChannelSummary[]> {
const guild = this.getGuild(guildId);
await guild.channels.fetch().catch(() => null);
const threads: ChannelSummary[] = [];
type ThreadFetchResult = {
threads: Map<string, { id: string; name: string; type: string }>;
};
for (const channel of guild.channels.cache.values()) {
const threadParent = channel as typeof channel & {
threads?: {
fetch: (options: {
archived: boolean;
limit: number;
}) => Promise<ThreadFetchResult>;
};
};
if (!threadParent.threads?.fetch) continue;
for (const archived of [false, true]) {
const fetched = await threadParent.threads
.fetch({ archived, limit: 100 })
.catch(() => null);
if (!fetched?.threads) continue;
for (const thread of fetched.threads.values()) {
threads.push({
id: thread.id,
name: `${channel.name} / ${thread.name}`,
type: thread.type,
});
}
}
}
return Array.from(
new Map(threads.map((thread) => [thread.id, thread])).values(),
).sort((a, b) => a.name.localeCompare(b.name));
}
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
if (!this.client.isReady()) {
throw new AppError(

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Streamer } from "@dank074/discord-video-stream";
import { Streamer } from "./streaming";
import { AudioPlayerStatus } from "@discordjs/voice";
import type { Client } from "discord.js-selfbot-v13";
import express, {
@@ -13,6 +13,7 @@ import express, {
import helmet from "helmet";
import * as prism from "prism-media";
import { WebSocketServer } from "ws";
import { config } from "./config";
import { AppError } from "./errors";
import { createChildLogger, logger } from "./logger";
import { MediaController } from "./media/mediaController";
@@ -43,6 +44,7 @@ const activeUsers = new Map<
type VoiceGlobals = typeof globalThis & {
moderationBroadcaster?: ModerationBroadcaster;
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
broadcastVideoToWeb?: (chunk: Buffer) => void;
updateActiveUser?: (
userId: string,
data: { username: string; avatar: string; speaking: boolean },
@@ -59,6 +61,10 @@ interface SharedUIState {
isStreaming: boolean;
}
interface MediaSettings {
musicVolume: number;
}
type SharedUIStatePatch = Partial<SharedUIState> & {
selectedGuild?: string;
};
@@ -73,6 +79,10 @@ const defaultSharedUIState: SharedUIState = {
isStreaming: false,
};
const defaultMediaSettings: MediaSettings = {
musicVolume: 1,
};
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
export function normalizeSharedUIState(
@@ -100,6 +110,17 @@ async function initializeSharedUIState() {
);
}
async function initializeMediaSettings(): Promise<MediaSettings> {
const stored = await getPersistedValue(
"media-settings",
defaultMediaSettings,
);
return {
...defaultMediaSettings,
...(stored as MediaSettings),
};
}
function getSharedUIState(): SharedUIState {
return { ...sharedUIState };
}
@@ -121,7 +142,9 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
if (typeof patch.selectedTextChannel === "string") {
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
}
if (["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")) {
if (
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")
) {
sharedUIState.activeTab = patch.activeTab as
| "voice"
| "messages"
@@ -171,6 +194,7 @@ export async function startWebserver(
voiceController: VoiceController,
) {
await initializeSharedUIState();
let mediaSettings = await initializeMediaSettings();
const app = express();
const server = http.createServer(app);
@@ -182,11 +206,23 @@ export async function startWebserver(
// Create broadcaster instance
const broadcaster = createBroadcaster();
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
(globalThis as any).ADMIN_PASSWORD = config.ADMIN_PASSWORD;
const streamer = new Streamer(_client);
const screenController = createScreenShareController({
getVoiceStatus: () => voiceController.getStatus(),
streamer,
useTranscoder: true,
onBeforeStreamStart: async (guildId: string, channelId: string) => {
await voiceController.disconnect();
// Wait for Discord gateway to fully process the disconnect
await new Promise((resolve) => setTimeout(resolve, 1500));
},
onAfterStreamEnd: async (guildId: string, channelId: string) => {
const current = voiceController.getStatus();
if (current.connected && current.activeGuildId === guildId) return;
await voiceController.connect(guildId, channelId);
},
});
const mediaController = new MediaController({
@@ -194,6 +230,11 @@ export async function startWebserver(
isBrowserStreaming: () => sharedUIState.isStreaming,
screenController,
onStateChange: (state) => broadcaster.mediaState(state),
initialMusicVolume: mediaSettings.musicVolume,
onMusicVolumeChange: async (volume) => {
mediaSettings = { ...mediaSettings, musicVolume: volume };
await setPersistedValue("media-settings", mediaSettings);
},
});
// Security headers. CSP disabled because the current static UI uses inline scripts/styles.
@@ -257,6 +298,16 @@ export async function startWebserver(
res.send(await getMetrics());
});
// Simple password-based auth
app.post("/api/auth/login", (req: Request, res: Response) => {
const { password } = req.body;
if (password === config.ADMIN_PASSWORD) {
res.json({ ok: true });
} else {
res.status(401).json({ error: "Invalid password" });
}
});
// Register route modules
app.use(
"/api",
@@ -268,12 +319,18 @@ export async function startWebserver(
voiceController,
patchSharedUIState,
broadcaster,
adminPassword: config.ADMIN_PASSWORD,
}),
);
app.use("/api", createMessageRoutes());
app.use("/api", createAnalysisRoutes());
app.use("/api", createSyncRoutes(_client));
app.use("/api", createMediaRoutes(mediaController));
app.use(
"/api",
createMediaRoutes(mediaController, {
adminPassword: config.ADMIN_PASSWORD,
}),
);
// Inbound: Discord PCM → tagged chunks → browser
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
@@ -293,6 +350,19 @@ export async function startWebserver(
}
};
// Outbound: server video stream (matroska chunks) -> browser clients
(globalThis as VoiceGlobals).broadcastVideoToWeb = (chunk: Buffer) => {
for (const client of broadcaster.getClients()) {
if (client.readyState === 1) {
try {
client.send(chunk);
} catch (err) {
wsLogger.warn({ err }, "Failed to send video chunk");
}
}
}
};
(globalThis as VoiceGlobals).updateActiveUser = (
userId: string,
data: { username: string; avatar: string; speaking: boolean },

33
test_dank.ts Normal file
View File

@@ -0,0 +1,33 @@
import { prepareStream, Encoders } from "@dank074/discord-video-stream";
import fs from "fs";
async function run() {
console.log("Starting prepareStream...");
const { command, output } = prepareStream("https://rr3---sn-2uuxa3vh-unte.googlevideo.com/videoplayback?expire=1779046518&ei=FsQJatGDGNqp9fwP4qz4SA&ip=180.252.24.35&id=o-APFvGry6yPgoap-1RT0pu59DxD-pcXC4oXtMQuCMtjOy&itag=18&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=618&met=1779024918%2C&mh=VD&mm=31%2C29&mn=sn-2uuxa3vh-unte%2Csn-oguelnze&ms=au%2Crdu&mv=m&mvi=3&pcm2cms=yes&pl=20&rms=au%2Cau&initcwndbps=763750&bui=AbKmrwofOLw_tOID4kBHnWgaXP2wnDlEYmbyHyrnZk1n7vjMaQIuY046T9MhH0PuL9JGJwj6YlwCr2Uu&spc=96Xrv8WI7iTS7MOF7Dvg-8a3RT-sMI9ux49zUa4Pg6GHkzXExSS0&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&cnr=14&ratebypass=yes&dur=19.063&lmt=1772437158054287&mt=1779024581&fvip=4&fexp=51565116%2C51565681&c=ANDROID_VR&txp=4530534&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AHEqNM4wRgIhAJe1vu37ssUQQm3scVgXY7NYDx_frKW1AZ4gHRdcqsUlAiEAkKt6jxaCNvaEh6jag1OWheo5qQeu3ObfCCoQIZ9xnCA%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpcm2cms%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRQIhAMkeJ6WrDFU7fTfSb6s_WbdDpn4J-4NqkfzKV3B_y1cgAiBJ7aExkhh-0hvIWwNorjDwoOkTIKIfmzx6o6Z3mxlazA%3D%3D", {
encoder: Encoders.software(),
width: 1280,
height: 720,
includeAudio: true,
minimizeLatency: false // Add this
});
const fileStream = fs.createWriteStream("/mnt/code/bete/test_out.nut");
output.pipe(fileStream);
command.on("error", (err, stdout, stderr) => {
console.error("FFMPEG ERROR:", err.message);
});
command.on("stderr", (stderrLine) => {
console.log("FFMPEG LOG:", stderrLine);
});
command.on("end", () => {
console.log("FFMPEG FINISHED");
process.exit(0);
});
setTimeout(() => {
try { command.kill("SIGKILL"); } catch(e) {}
process.exit(0);
}, 10000);
}
run();

27
test_dank2.ts Normal file
View File

@@ -0,0 +1,27 @@
import { prepareStream, Encoders } from "@dank074/discord-video-stream";
import { demux } from "@dank074/discord-video-stream/dist/media/LibavDemuxer.js";
async function run() {
console.log("Starting prepareStream...");
const { command, output } = prepareStream("https://samplelib.com/preview/mp4/sample-5s.mp4", {
encoder: Encoders.software(),
width: 1280,
height: 720,
includeAudio: true,
minimizeLatency: false // Add this
});
try {
const { video, audio } = await demux(output, { format: "nut" });
console.log("DEMUX VIDEO:", !!video);
console.log("DEMUX AUDIO:", !!audio);
} catch(e) {
console.error("DEMUX ERR:", e);
}
setTimeout(() => {
try { command.kill("SIGKILL"); } catch(e) {}
process.exit(0);
}, 10000);
}
run();

BIN
test_out.nut Normal file

Binary file not shown.

18
test_stream.ts Normal file
View File

@@ -0,0 +1,18 @@
import { prepareStream } from "@dank074/discord-video-stream";
import { demux } from "@dank074/discord-video-stream/dist/media/LibavDemuxer.js";
import { Encoders } from "@dank074/discord-video-stream/dist/media/encoders/index.js";
async function run() {
const { command, output } = prepareStream("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", {
encoder: Encoders.software(),
width: 1280,
height: 720,
includeAudio: true
});
const { video, audio } = await demux(output, { format: "nut" });
console.log("Video found:", !!video);
console.log("Audio found:", !!audio);
process.exit(0);
}
run();

View File

@@ -194,12 +194,13 @@ describe("MediaController", () => {
expect(state).toEqual({
playing: false,
activeMode: null,
musicVolume: 1,
current: null,
queue: [],
});
});
it("starts screen share mode without resolving music source", async () => {
it("starts screen share mode by resolving the video source", async () => {
const screenPlayback = deferred();
const screenController: ScreenShareController = {
isActive: vi.fn(() => false),
@@ -208,7 +209,7 @@ describe("MediaController", () => {
stop: vi.fn(),
})),
};
const resolveMediaSource = vi.fn(async (input) => source(input));
const resolveMediaSource = vi.fn(async (input, mode) => source(input));
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
@@ -224,7 +225,7 @@ describe("MediaController", () => {
expect(screenController.start).toHaveBeenCalledWith(
"https://youtu.be/video",
);
expect(resolveMediaSource).not.toHaveBeenCalled();
expect(resolveMediaSource).toHaveBeenCalledWith("https://youtu.be/video", "screen");
expect(state).toMatchObject({ playing: true, activeMode: "screen" });
});

View File

@@ -5,6 +5,7 @@ type Spawn = typeof nodeSpawn;
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { StreamType } from "@discordjs/voice";
import type {
DiscordAudioPlayer,
DiscordPlayerOwner,
@@ -23,13 +24,15 @@ class FakeProcess extends EventEmitter {
}
describe("createMusicPlayer", () => {
it("spawns ffmpeg as Ogg Opus and passes stdout to Discord", async () => {
it("spawns ffmpeg as raw PCM and passes stdout to Discord", async () => {
const proc = new FakeProcess();
const spawn = vi.fn(() => proc);
const discordPlayer: DiscordAudioPlayer = {
isConnected: () => true,
playStream: vi.fn(),
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
getMusicVolume: vi.fn(() => 1),
setMusicVolume: vi.fn(),
pause: vi.fn(),
unpause: vi.fn(() => true),
stop: vi.fn(),
@@ -57,18 +60,21 @@ describe("createMusicPlayer", () => {
"https://example.com/song.mp3",
"-vn",
"-acodec",
"libopus",
"pcm_s16le",
"-ar",
"48000",
"-ac",
"2",
"-f",
"ogg",
"s16le",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music");
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music", {
inputType: StreamType.Raw,
inlineVolume: true,
});
});
it("rejects playback when Discord is not connected", () => {
@@ -77,6 +83,8 @@ describe("createMusicPlayer", () => {
isConnected: () => false,
playStream: vi.fn(),
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
getMusicVolume: vi.fn(() => 1),
setMusicVolume: vi.fn(),
pause: vi.fn(),
unpause: vi.fn(() => true),
stop: vi.fn(),
@@ -102,6 +110,8 @@ describe("createMusicPlayer", () => {
isConnected: () => true,
playStream: vi.fn(),
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
getMusicVolume: vi.fn(() => 1),
setMusicVolume: vi.fn(),
pause: vi.fn(),
unpause: vi.fn(() => true),
stop: vi.fn(),
@@ -128,6 +138,8 @@ describe("createMusicPlayer", () => {
isConnected: () => true,
playStream: vi.fn(),
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
getMusicVolume: vi.fn(() => 1),
setMusicVolume: vi.fn(),
pause: vi.fn(),
unpause: vi.fn(() => true),
stop: vi.fn(),

View File

@@ -1,11 +1,13 @@
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { AppError } from "../../src/errors";
import type { DiscordPlayerOwner } from "../../src/media/mediaTypes";
import { createScreenShareController } from "../../src/media/screenShareController";
function createDependencies() {
const output = new PassThrough();
const session = {
play: vi.fn(() => new Promise<void>(() => {})),
stop: vi.fn(),
};
return {
getVoiceStatus: vi.fn(() => ({
connected: true,
@@ -14,12 +16,11 @@ function createDependencies() {
})),
getPlayerOwner: vi.fn((): DiscordPlayerOwner => "none"),
getDirectVideoUrl: vi.fn(async () => "https://cdn.example.com/video.mp4"),
prepareStream: vi.fn(() => ({
command: { kill: vi.fn() },
output,
})),
playStream: vi.fn(() => new Promise<void>(() => {})),
streamer: { id: "streamer" },
streamer: {
createSession: vi.fn(async () => session),
client: {},
},
session,
};
}
@@ -33,14 +34,17 @@ describe("createScreenShareController", () => {
expect(dependencies.getDirectVideoUrl).toHaveBeenCalledWith(
"https://youtu.be/video",
);
expect(dependencies.prepareStream).toHaveBeenCalledWith(
"https://cdn.example.com/video.mp4",
expect.objectContaining({ includeAudio: true }),
expect(dependencies.streamer.createSession).toHaveBeenCalledWith(
"guild-1",
"channel-1",
);
expect(dependencies.playStream).toHaveBeenCalledWith(
dependencies.prepareStream.mock.results[0].value.output,
dependencies.streamer,
{ type: "go-live" },
expect(dependencies.session.play).toHaveBeenCalledWith(
"https://cdn.example.com/video.mp4",
expect.objectContaining({
includeAudio: true,
fps: 30,
bitrate: 2500,
}),
);
expect(controller.isActive()).toBe(true);
playback.stop();
@@ -79,16 +83,13 @@ describe("createScreenShareController", () => {
it("wraps stream startup failures", async () => {
const dependencies = createDependencies();
dependencies.playStream.mockImplementation(() => {
dependencies.session.play.mockImplementation(() => {
throw new Error("go live failed");
});
const controller = createScreenShareController(dependencies);
await expect(
controller.start("https://youtu.be/video"),
).rejects.toMatchObject({
code: "SCREEN_STREAM_FAILED",
statusCode: 500,
} satisfies Partial<AppError>);
const playback = await controller.start("https://youtu.be/video");
await expect(playback.done).rejects.toThrow("go live failed");
});
});

View File

@@ -0,0 +1,75 @@
import { beforeAll, describe, expect, it } from "vitest";
import { config } from "../../src/config";
import { runModerationAnalysis } from "../../src/moderation/llmModerationClient";
import type { MessageRecord } from "../../src/moderation/types";
describe("LLM Live Integration Test", () => {
// Hanya jalankan jika API Key tersedia
const hasApiKey =
!!config.AI_LLM_API_KEY && config.AI_LLM_API_KEY !== "your-api-key";
it.runIf(hasApiKey)(
"should successfully call real LLM API and parse response",
async () => {
console.log(`Using Model: ${config.AI_LLM_MODEL}`);
console.log(`Base URL: ${config.AI_LLM_BASE_URL}`);
const mockMessages: MessageRecord[] = [
{
id: "test-msg-1",
guild_id: "guild-1",
channel_id: "channel-1",
thread_id: null,
user_id: "user-1",
username: "Tester",
avatar_url: null,
content: "This is a clean test message.",
edited_content: null,
created_at: Date.now(),
edited_at: null,
deleted_at: null,
type: "text",
metadata: null,
},
{
id: "test-msg-2",
guild_id: "guild-1",
channel_id: "channel-1",
thread_id: null,
user_id: "user-2",
username: "BadActor",
avatar_url: null,
content: "I will kill you and steal your data! DIE!",
edited_content: null,
created_at: Date.now() + 1000,
edited_at: null,
deleted_at: null,
type: "text",
metadata: null,
},
];
const result = await runModerationAnalysis({
targets: mockMessages,
contextText: "Testing moderation system stability.",
});
console.log(
"Raw Response received (first 100 chars):",
JSON.stringify(result.raw).substring(0, 100),
);
expect(result.results).toHaveLength(2);
const cleanMsg = result.results.find((r) => r.messageId === "test-msg-1");
const badMsg = result.results.find((r) => r.messageId === "test-msg-2");
expect(cleanMsg?.status).toBe("clean");
expect(["warn", "flagged"]).toContain(badMsg?.status);
console.log("Clean Message Result:", cleanMsg);
console.log("Bad Message Result:", badMsg);
},
30000,
); // 30s timeout untuk LLM
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { PassThrough } from "node:stream";
vi.mock("node:child_process", async () => {
const actual = await vi.importActual("node:child_process");
return {
...actual,
spawn: (cmd: string, args: string[], opts: any) => {
const stdout = new PassThrough();
const stderr = new PassThrough();
const listeners: Record<string, Function[]> = {};
const proc: any = {
stdout,
stderr,
kill: vi.fn(() => {
(listeners.exit || []).forEach((fn) => fn(0, "SIGKILL"));
}),
on: (ev: string, fn: Function) => {
listeners[ev] = listeners[ev] || [];
listeners[ev].push(fn);
},
off: (ev: string, fn: Function) => {
listeners[ev] = (listeners[ev] || []).filter((f) => f !== fn);
},
stdoutWrite: (d: Buffer | string) => stdout.write(d),
};
setTimeout(() => {
(listeners.exit || []).forEach((fn) => fn(null, null));
}, 10);
return proc;
},
};
});
import { playTranscodedPreparedStream } from "../../src/streaming/index";
describe("playTranscodedPreparedStream", () => {
it("pipes transcoder output to session and broadcasts to web", async () => {
// mock global broadcast
const broadcasts: Buffer[] = [];
(globalThis as any).broadcastVideoToWeb = (chunk: Buffer) => broadcasts.push(Buffer.from(chunk));
const session = {
connection: { channel: { id: "c" } },
stream: { playVideo: () => null, playAudio: () => null },
play: vi.fn().mockImplementation(async (readable) => {
// consume a bit from readable to simulate playback
readable.on("data", (d: Buffer) => {});
// resolve after a short delay
await new Promise((r) => setTimeout(r, 5));
}),
stop: vi.fn(),
} as any;
await playTranscodedPreparedStream("http://example.test/stream", session, { fps: 30 });
expect(session.play).toHaveBeenCalled();
expect(broadcasts.length).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from "vitest";
import { PassThrough } from "node:stream";
// Mock spawn to avoid calling real ffmpeg
vi.mock("node:child_process", async () => {
const actual = await vi.importActual("node:child_process");
return {
...actual,
spawn: (cmd: string, args: string[], opts: any) => {
const stdout = new PassThrough();
const stderr = new PassThrough();
const listeners: Record<string, Function[]> = {};
const proc: any = {
stdout,
stderr,
kill: vi.fn(() => {
// emit exit when killed
(listeners.exit || []).forEach((fn) => fn(0, "SIGKILL"));
}),
on: (ev: string, fn: Function) => {
listeners[ev] = listeners[ev] || [];
listeners[ev].push(fn);
},
off: (ev: string, fn: Function) => {
listeners[ev] = (listeners[ev] || []).filter((f) => f !== fn);
},
stdoutWrite: (d: Buffer | string) => stdout.write(d),
};
// simulate async start
setTimeout(() => {
(listeners.exit || []).forEach((fn) => fn(null, null));
}, 10);
return proc;
},
};
});
import { prepareTranscoder } from "../../src/streaming/transcoder";
describe("Transcoder", () => {
it("starts ffmpeg and returns output stream and command", () => {
const { transcoder, command, output } = prepareTranscoder("http://example.test/video", { fps: 24 });
expect(transcoder).toBeTruthy();
expect(command).toBeTruthy();
expect(output).toBeTruthy();
expect(typeof command.kill).toBe("function");
// write some data and ensure output is readable
const wrote = command.stdoutWrite?.("hello");
expect(output.readable).toBe(true);
transcoder.stop();
});
});

View File

@@ -1,20 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const videoStreamPackage = JSON.parse(
readFileSync("vendor/Discord-video-stream/package.json", "utf8"),
) as {
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
describe("Discord video stream workspace dependencies", () => {
it("uses the local selfbot workspace package for development", () => {
expect(videoStreamPackage.devDependencies?.["discord.js-selfbot-v13"]).toBe(
"workspace:*",
);
expect(
videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"],
).toBe("^3.6.0");
});
});