Compare commits
24
Commits
9ad7d16a17
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a2d9abc6 | ||
|
|
d33942cc23 | ||
|
|
aa8d5dfd41 | ||
|
|
8584030633 | ||
|
|
f35710db3f | ||
|
|
0c7930bd01 | ||
|
|
e22e620bae | ||
|
|
eda32720c8 | ||
|
|
c0f66c78a3 | ||
|
|
b8a6f40b1b | ||
|
|
4931e6d1ca | ||
|
|
a3e6c4695a | ||
|
|
6de5342703 | ||
|
|
5a926dbd17 | ||
|
|
7985efbef6 | ||
|
|
71889ab689 | ||
|
|
518577d79d | ||
|
|
d04093ec6e | ||
|
|
05feb697f0 | ||
|
|
a5b5ccf5b0 | ||
|
|
99ec528a03 | ||
|
|
7dedac2094 | ||
|
|
9b211f05cf | ||
|
|
4825dc6d4d |
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.test
|
||||
.git
|
||||
.github
|
||||
recordings
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
test_out.nut
|
||||
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Mirror to Gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mirror to Gitea
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/dc-recorder.git"
|
||||
git push --mirror gitea
|
||||
echo "✅ Mirrored to Gitea (MythEclipse/dc-recorder)"
|
||||
+2
-1
@@ -4,4 +4,5 @@ recordings
|
||||
dist/
|
||||
public/app/
|
||||
.muxer-queue.**
|
||||
.claude/
|
||||
.claude/
|
||||
.env.test
|
||||
+1
-3
@@ -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
@@ -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"]
|
||||
@@ -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();
|
||||
@@ -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!"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
+49
-32
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface MediaItem {
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+38
-628
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
packages:
|
||||
- .
|
||||
- vendor/discord.js-selfbot-v13
|
||||
- vendor/Discord-video-stream
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@discordjs/opus'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+19
-2
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-2
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+7
-1
@@ -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 {};
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface MediaQueueItem {
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
|
||||
+47
-3
@@ -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();
|
||||
|
||||
+1
-1
@@ -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",
|
||||
);
|
||||
|
||||
+98
-42
@@ -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;
|
||||
}
|
||||
|
||||
+106
-91
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+73
-3
@@ -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 },
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user