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/
|
dist/
|
||||||
public/app/
|
public/app/
|
||||||
.muxer-queue.**
|
.muxer-queue.**
|
||||||
.claude/
|
.claude/
|
||||||
|
.env.test
|
||||||
+1
-3
@@ -1,6 +1,4 @@
|
|||||||
[submodule "vendor/discord.js-selfbot-v13"]
|
[submodule "vendor/discord.js-selfbot-v13"]
|
||||||
path = vendor/discord.js-selfbot-v13
|
path = vendor/discord.js-selfbot-v13
|
||||||
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Discord Moderation Dashboard</title>
|
<title>Discord Moderation Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+49
-32
@@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel";
|
|||||||
import { ReviewPanel } from "./components/review/ReviewPanel";
|
import { ReviewPanel } from "./components/review/ReviewPanel";
|
||||||
import { Tabs, TabsContent } from "./components/ui/tabs";
|
import { Tabs, TabsContent } from "./components/ui/tabs";
|
||||||
import { VoicePanel } from "./components/voice/VoicePanel";
|
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||||
|
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||||
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||||
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||||
import { useMediaControl } from "./hooks/useMediaControl";
|
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 [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
|
||||||
const audioContextListenRef = useRef<AudioContext | null>(null);
|
const audioContextListenRef = useRef<AudioContext | null>(null);
|
||||||
const audioContextTransmitRef = useRef<AudioContext | null>(null);
|
const audioContextTransmitRef = useRef<AudioContext | null>(null);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
@@ -144,16 +146,22 @@ export default function App() {
|
|||||||
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
|
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
if (selectedVoiceGuild) {
|
||||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedVoiceGuild]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
if (selectedTextGuild) {
|
||||||
}, [selectedTextGuild, voice.loadTextTargets]);
|
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedTextGuild]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
if (selectedTextChannel) {
|
||||||
}, [selectedTextChannel, messages.fetchMessages]);
|
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [selectedTextChannel]);
|
||||||
|
|
||||||
const toggleListening = useCallback(async () => {
|
const toggleListening = useCallback(async () => {
|
||||||
if (isListening) {
|
if (isListening) {
|
||||||
@@ -192,34 +200,43 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
<TabsContent value="voice">
|
<TabsContent value="voice">
|
||||||
<VoicePanel
|
{!isAuthenticated ? (
|
||||||
guilds={voice.guilds}
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
channels={voice.voiceChannels}
|
) : (
|
||||||
selectedGuild={selectedVoiceGuild}
|
<VoicePanel
|
||||||
selectedChannel={selectedVoiceChannel}
|
guilds={voice.guilds}
|
||||||
status={voice.voiceStatus}
|
channels={voice.voiceChannels}
|
||||||
loading={voice.loading}
|
selectedGuild={selectedVoiceGuild}
|
||||||
activeSpeakers={activeSpeakers}
|
selectedChannel={selectedVoiceChannel}
|
||||||
levels={levels}
|
status={voice.voiceStatus}
|
||||||
isListening={isListening}
|
loading={voice.loading}
|
||||||
isStreaming={isStreaming}
|
activeSpeakers={activeSpeakers}
|
||||||
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
|
levels={levels}
|
||||||
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
|
isListening={isListening}
|
||||||
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
|
isStreaming={isStreaming}
|
||||||
onDisconnect={() => voice.leaveVoice()}
|
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
|
||||||
onListenToggle={toggleListening}
|
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
|
||||||
onStreamingToggle={toggleStreaming}
|
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
|
||||||
/>
|
onDisconnect={() => voice.leaveVoice()}
|
||||||
|
onListenToggle={toggleListening}
|
||||||
|
onStreamingToggle={toggleStreaming}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="media">
|
<TabsContent value="media">
|
||||||
<MediaPanel
|
{!isAuthenticated ? (
|
||||||
state={media.mediaState}
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
loading={media.loading}
|
) : (
|
||||||
onQueueMusic={(source) => media.enqueue(source, "music")}
|
<MediaPanel
|
||||||
onStartScreen={(source) => media.enqueue(source, "screen")}
|
state={media.mediaState}
|
||||||
onSkip={media.skip}
|
loading={media.loading}
|
||||||
onStop={media.stop}
|
onQueueMusic={(source) => media.enqueue(source, "music")}
|
||||||
/>
|
onStartScreen={(source) => media.enqueue(source, "screen")}
|
||||||
|
onSkip={media.skip}
|
||||||
|
onStop={media.stop}
|
||||||
|
onVolumeChange={media.setVolume}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="messages">
|
<TabsContent value="messages">
|
||||||
<MessagesPanel
|
<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> {
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const password = localStorage.getItem("admin-password");
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(password ? { "X-Admin-Password": password } : {}),
|
||||||
|
},
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,10 @@ export function skipMedia(): Promise<MediaState> {
|
|||||||
export function stopMedia(): Promise<MediaState> {
|
export function stopMedia(): Promise<MediaState> {
|
||||||
return request<MediaState>('/api/media/stop', { method: 'POST' });
|
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`);
|
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> {
|
export function getVoiceStatus(): Promise<VoiceStatus> {
|
||||||
return request<VoiceStatus>('/api/status');
|
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;
|
onStartScreen: (source: string) => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onStop: () => 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 (
|
return (
|
||||||
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||||
<Tabs defaultValue="music" className="min-w-0">
|
<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>
|
<TabsTrigger value="screen">Screen Share</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="music">
|
<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>
|
||||||
<TabsContent value="screen">
|
<TabsContent value="screen">
|
||||||
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
|
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
|
||||||
|
|||||||
@@ -1,18 +1,42 @@
|
|||||||
import { Music2 } from "lucide-react";
|
import { Music2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
|
|
||||||
interface MusicPlayerProps {
|
interface MusicPlayerProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
onQueue: (source: string) => void;
|
onQueue: (source: string) => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onStop: () => 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 [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 submit = () => {
|
||||||
const trimmed = source.trim();
|
const trimmed = source.trim();
|
||||||
@@ -34,6 +58,21 @@ export function MusicPlayer({ loading, onQueue, onSkip, onStop }: MusicPlayerPro
|
|||||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||||
placeholder="YouTube URL, Spotify track, or search terms"
|
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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
|
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
|
||||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</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">
|
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<img
|
<img
|
||||||
src={message.avatar_url ?? "/default-avatar.png"}
|
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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";
|
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() {
|
export function useMediaControl() {
|
||||||
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
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(() => {
|
useEffect(() => {
|
||||||
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
}, [refreshMedia]);
|
}, [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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchMessages = useCallback(async (channelId?: string) => {
|
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||||
|
if (!channelId) {
|
||||||
|
setMessages([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: "80" });
|
const params = new URLSearchParams({ limit: "80" });
|
||||||
if (channelId) params.set("channel", channelId);
|
params.set("channel", channelId);
|
||||||
const result = await listMessages(params);
|
const result = await listMessages(params);
|
||||||
setMessages(result.data);
|
setMessages(result.data);
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
disconnectVoice,
|
disconnectVoice,
|
||||||
getGuilds,
|
getGuilds,
|
||||||
getTextChannels,
|
getTextChannels,
|
||||||
getThreads,
|
|
||||||
getVoiceChannels,
|
getVoiceChannels,
|
||||||
getVoiceStatus,
|
getVoiceStatus,
|
||||||
} from "../api/voice";
|
} from "../api/voice";
|
||||||
@@ -46,13 +45,9 @@ export function useVoiceControl() {
|
|||||||
setTextChannels([]);
|
setTextChannels([]);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const [channels, threads] = await Promise.all([
|
const channels = await getTextChannels(guildId);
|
||||||
getTextChannels(guildId),
|
setTextChannels(channels);
|
||||||
getThreads(guildId).catch(() => []),
|
return channels;
|
||||||
]);
|
|
||||||
const combined = [...channels, ...threads];
|
|
||||||
setTextChannels(combined);
|
|
||||||
return combined;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
|
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface MediaItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaItem | null;
|
current: MediaItem | null;
|
||||||
queue: MediaItem[];
|
queue: MediaItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dank074/discord-video-stream": "workspace:*",
|
"@dank074/discord-video-stream": "^6.0.0",
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.1",
|
"@discordjs/voice": "^0.19.1",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@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:
|
packages:
|
||||||
- .
|
- .
|
||||||
- vendor/discord.js-selfbot-v13
|
- vendor/discord.js-selfbot-v13
|
||||||
- vendor/Discord-video-stream
|
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@discordjs/opus'
|
- '@discordjs/opus'
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const configSchema = z
|
|||||||
POSTGRES_DB: z.string().optional(),
|
POSTGRES_DB: z.string().optional(),
|
||||||
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
||||||
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
||||||
|
ADMIN_PASSWORD: z.string().default("admin123"),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (!value.AI_ANALYSIS_ENABLED) {
|
if (!value.AI_ANALYSIS_ENABLED) {
|
||||||
|
|||||||
+19
-2
@@ -19,11 +19,15 @@ let db:
|
|||||||
*/
|
*/
|
||||||
export async function initializeDatabase() {
|
export async function initializeDatabase() {
|
||||||
if (db !== null) {
|
if (db !== null) {
|
||||||
logger.debug("Database already initialized, skipping");
|
|
||||||
return db;
|
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;
|
let pool: Pool;
|
||||||
|
|
||||||
// Use DATABASE_URL if available, otherwise build from individual variables
|
// Use DATABASE_URL if available, otherwise build from individual variables
|
||||||
@@ -46,12 +50,25 @@ export async function initializeDatabase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db = drizzlePostgres(pool, { schema });
|
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");
|
logger.info("PostgreSQL database initialized");
|
||||||
} else {
|
} else {
|
||||||
const sqlite = new Database(".muxer-queue.db");
|
const sqlite = new Database(".muxer-queue.db");
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
|
||||||
db = drizzleSqlite(sqlite, { schema });
|
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");
|
logger.info("SQLite database initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "./mock-crc";
|
||||||
import "libsodium-wrappers";
|
import "libsodium-wrappers";
|
||||||
import "@snazzah/davey";
|
import "@snazzah/davey";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
@@ -72,6 +73,14 @@ async function initializeApp() {
|
|||||||
process.exit(1);
|
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 () => {
|
client.on("ready", async () => {
|
||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
registerMessageCapture(client);
|
registerMessageCapture(client);
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import { createMusicPlayer } from "./musicPlayer";
|
|||||||
export interface MediaControllerDependencies {
|
export interface MediaControllerDependencies {
|
||||||
isVoiceConnected?: () => boolean;
|
isVoiceConnected?: () => boolean;
|
||||||
isBrowserStreaming?: () => boolean;
|
isBrowserStreaming?: () => boolean;
|
||||||
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
|
||||||
musicPlayer?: MusicPlayer;
|
musicPlayer?: MusicPlayer;
|
||||||
screenController?: ScreenShareController;
|
screenController?: ScreenShareController;
|
||||||
onStateChange?: (state: MediaState) => void;
|
onStateChange?: (state: MediaState) => void;
|
||||||
|
initialMusicVolume?: number;
|
||||||
|
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
|
||||||
|
setMusicVolume?: (volume: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
@@ -31,9 +34,18 @@ export class MediaController {
|
|||||||
private skipInProgress = false;
|
private skipInProgress = false;
|
||||||
private screenPlayback: ScreenSharePlayback | null = null;
|
private screenPlayback: ScreenSharePlayback | null = null;
|
||||||
private activeMode: MediaMode | null = null;
|
private activeMode: MediaMode | null = null;
|
||||||
|
private musicVolume: number;
|
||||||
|
private readonly setPlayerMusicVolume: (volume: number) => void;
|
||||||
|
|
||||||
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||||
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
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 {
|
getState(): MediaState {
|
||||||
@@ -42,23 +54,52 @@ export class MediaController {
|
|||||||
playing:
|
playing:
|
||||||
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||||
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||||
|
musicVolume: this.musicVolume,
|
||||||
...snapshot,
|
...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(
|
async queue(
|
||||||
source: string,
|
source: string,
|
||||||
options: QueueMediaOptions = {},
|
options: QueueMediaOptions = {},
|
||||||
): Promise<MediaState> {
|
): Promise<MediaState> {
|
||||||
const mode = options.mode ?? "music";
|
const mode = options.mode ?? "music";
|
||||||
|
|
||||||
|
const resolved = await (
|
||||||
|
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||||
|
)(source, mode);
|
||||||
|
|
||||||
if (mode === "screen") {
|
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();
|
this.assertCanStartMusic();
|
||||||
const resolved = await (
|
|
||||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
|
||||||
)(source);
|
|
||||||
this.queueStore.add(resolved, mode, options.requestedBy);
|
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||||
this.startNextIfIdle();
|
this.startNextIfIdle();
|
||||||
return this.emitState();
|
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?.()) {
|
if (this.dependencies.isBrowserStreaming?.()) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"Stop browser microphone streaming before playing media",
|
"Stop browser microphone streaming before playing media",
|
||||||
@@ -122,14 +159,6 @@ export class MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startScreen(source: string): Promise<MediaState> {
|
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;
|
const screenController = this.dependencies.screenController;
|
||||||
if (!screenController) {
|
if (!screenController) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -201,3 +230,8 @@ export class MediaController {
|
|||||||
return state;
|
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 { existsSync, statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import type { ResolvedMediaSource } from "./mediaTypes";
|
import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
|
||||||
import { createPlayDlResolver } from "./playDlResolver";
|
import { createPlayDlResolver } from "./playDlResolver";
|
||||||
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
||||||
|
|
||||||
@@ -18,7 +18,10 @@ export function createMediaResolver(
|
|||||||
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
||||||
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
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();
|
const source = input.trim();
|
||||||
if (!source) {
|
if (!source) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -31,13 +34,17 @@ export function createMediaResolver(
|
|||||||
const url = parseUrl(source);
|
const url = parseUrl(source);
|
||||||
if (url && isYouTubeUrl(url)) {
|
if (url && isYouTubeUrl(url)) {
|
||||||
const metadata = await ytdlp.getMetadata(source);
|
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" };
|
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url && isSpotifyTrackUrl(url)) {
|
if (url && isSpotifyTrackUrl(url)) {
|
||||||
const result = await playDlResolver.resolveSpotifyTrack(source);
|
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" };
|
return { source: directUrl, title: result.title, kind: "spotify" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +62,9 @@ export function createMediaResolver(
|
|||||||
|
|
||||||
if (!url && !looksLikeUrl(source)) {
|
if (!url && !looksLikeUrl(source)) {
|
||||||
const result = await playDlResolver.searchYouTube(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" };
|
return { source: directUrl, title: result.title, kind: "search" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
import type { StreamType } from "@discordjs/voice";
|
||||||
|
|
||||||
export type MediaMode = "music" | "screen";
|
export type MediaMode = "music" | "screen";
|
||||||
export type MediaSourceKind =
|
export type MediaSourceKind =
|
||||||
@@ -26,6 +27,7 @@ export interface MediaQueueItem extends ResolvedMediaSource {
|
|||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
activeMode: MediaMode | null;
|
activeMode: MediaMode | null;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
@@ -56,11 +58,23 @@ export interface ScreenShareController {
|
|||||||
|
|
||||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||||
|
|
||||||
|
export interface DiscordPlayOptions {
|
||||||
|
inputType?: StreamType;
|
||||||
|
inlineVolume?: boolean;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscordAudioPlayer {
|
export interface DiscordAudioPlayer {
|
||||||
getOwner(): DiscordPlayerOwner;
|
getOwner(): DiscordPlayerOwner;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
playStream(stream: Readable, owner: DiscordPlayerOwner): void;
|
playStream(
|
||||||
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options?: DiscordPlayOptions,
|
||||||
|
): void;
|
||||||
pause(owner?: DiscordPlayerOwner): void;
|
pause(owner?: DiscordPlayerOwner): void;
|
||||||
unpause(owner?: DiscordPlayerOwner): boolean;
|
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||||
stop(owner?: DiscordPlayerOwner): void;
|
stop(owner?: DiscordPlayerOwner): void;
|
||||||
|
getMusicVolume(): number;
|
||||||
|
setMusicVolume(volume: number): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { spawn as nodeSpawn } from "node:child_process";
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
|
import { StreamType } from "@discordjs/voice";
|
||||||
import { discordPlayer } from "../player";
|
import { discordPlayer } from "../player";
|
||||||
import type {
|
import type {
|
||||||
DiscordAudioPlayer,
|
DiscordAudioPlayer,
|
||||||
@@ -30,7 +31,10 @@ export function createMusicPlayer(
|
|||||||
}) as unknown as ChildProcessWithoutNullStreams;
|
}) as unknown as ChildProcessWithoutNullStreams;
|
||||||
proc.stderr.resume();
|
proc.stderr.resume();
|
||||||
|
|
||||||
audioPlayer.playStream(proc.stdout, "music");
|
audioPlayer.playStream(proc.stdout, "music", {
|
||||||
|
inputType: StreamType.Raw,
|
||||||
|
inlineVolume: true,
|
||||||
|
});
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let released = false;
|
let released = false;
|
||||||
@@ -81,13 +85,13 @@ export function buildFfmpegArgs(source: string): string[] {
|
|||||||
source,
|
source,
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec",
|
"-acodec",
|
||||||
"libopus",
|
"pcm_s16le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"48000",
|
"48000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"2",
|
"2",
|
||||||
"-f",
|
"-f",
|
||||||
"ogg",
|
"s16le",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Readable } from "node:stream";
|
|
||||||
import {
|
import {
|
||||||
playStream as defaultPlayStream,
|
Streamer,
|
||||||
prepareStream as defaultPrepareStream,
|
playPreparedStream,
|
||||||
Encoders,
|
} from "../streaming";
|
||||||
Utils,
|
|
||||||
} from "@dank074/discord-video-stream";
|
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
|
import { createChildLogger } from "../logger";
|
||||||
import { discordPlayer } from "../player";
|
import { discordPlayer } from "../player";
|
||||||
|
|
||||||
|
const logger = createChildLogger("screen-share");
|
||||||
|
|
||||||
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
||||||
import { createYtDlp } from "./ytdlp";
|
import { createYtDlp } from "./ytdlp";
|
||||||
|
|
||||||
@@ -16,29 +17,16 @@ export interface ScreenShareVoiceStatus {
|
|||||||
activeChannelId: string | null;
|
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 {
|
export interface ScreenShareControllerDependencies {
|
||||||
getVoiceStatus: () => ScreenShareVoiceStatus;
|
getVoiceStatus: () => ScreenShareVoiceStatus;
|
||||||
getPlayerOwner?: () => DiscordPlayerOwner;
|
getPlayerOwner?: () => DiscordPlayerOwner;
|
||||||
getDirectVideoUrl?: (source: string) => Promise<string>;
|
getDirectVideoUrl?: (source: string) => Promise<string>;
|
||||||
prepareStream?: PrepareScreenStream;
|
streamer: Streamer;
|
||||||
playStream?: PlayScreenStream;
|
useTranscoder?: boolean;
|
||||||
streamer: unknown;
|
onBeforeStreamStart?: (guildId: string, channelId: string) => Promise<void> | void;
|
||||||
|
onAfterStreamEnd?: (guildId: string, channelId: string) => Promise<void> | void;
|
||||||
|
onStreamStart?: () => void;
|
||||||
|
onStreamEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createScreenShareController(
|
export function createScreenShareController(
|
||||||
@@ -51,12 +39,6 @@ export function createScreenShareController(
|
|||||||
const getDirectVideoUrl =
|
const getDirectVideoUrl =
|
||||||
dependencies.getDirectVideoUrl ??
|
dependencies.getDirectVideoUrl ??
|
||||||
((source) => ytdlp.getDirectVideoUrl(source));
|
((source) => ytdlp.getDirectVideoUrl(source));
|
||||||
const prepareStream =
|
|
||||||
dependencies.prepareStream ??
|
|
||||||
(defaultPrepareStream as unknown as PrepareScreenStream);
|
|
||||||
const playStream =
|
|
||||||
dependencies.playStream ??
|
|
||||||
(defaultPlayStream as unknown as PlayScreenStream);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
@@ -65,6 +47,20 @@ export function createScreenShareController(
|
|||||||
|
|
||||||
async start(source: string): Promise<ScreenSharePlayback> {
|
async start(source: string): Promise<ScreenSharePlayback> {
|
||||||
const status = dependencies.getVoiceStatus();
|
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 (
|
if (
|
||||||
!status.connected ||
|
!status.connected ||
|
||||||
!status.activeGuildId ||
|
!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);
|
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const directUrl = await getDirectVideoUrl(source);
|
const directUrl = await getDirectVideoUrl(source);
|
||||||
const { command, output } = prepareStream(directUrl, {
|
logger.info(
|
||||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
{
|
||||||
height: 720,
|
guildId,
|
||||||
frameRate: 30,
|
channelId,
|
||||||
bitrateVideo: 2500,
|
},
|
||||||
bitrateVideoMax: 4000,
|
"Creating screen share session",
|
||||||
includeAudio: true,
|
);
|
||||||
videoCodec: Utils.normalizeVideoCodec("H264"),
|
await dependencies.onBeforeStreamStart?.(guildId, channelId);
|
||||||
});
|
voiceReleased = true;
|
||||||
|
const session = await dependencies.streamer.createSession(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
dependencies.onStreamStart?.();
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const done = playStream(output, dependencies.streamer, {
|
const playFn = dependencies.useTranscoder
|
||||||
type: "go-live",
|
? (await import("../streaming")).playTranscodedPreparedStream
|
||||||
|
: (await import("../streaming")).playPreparedStream;
|
||||||
|
|
||||||
|
const done = playFn(directUrl, session, {
|
||||||
|
fps: 30,
|
||||||
|
bitrate: 2500,
|
||||||
|
includeAudio: true,
|
||||||
|
presetH26x: "superfast",
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
active = null;
|
active = null;
|
||||||
|
dependencies.onStreamEnd?.();
|
||||||
|
return restoreVoice();
|
||||||
});
|
});
|
||||||
|
done.catch(() => undefined);
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
},
|
||||||
|
"Screen share session started",
|
||||||
|
);
|
||||||
|
|
||||||
active = {
|
active = {
|
||||||
done,
|
done,
|
||||||
stop() {
|
stop() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
stopped = true;
|
stopped = true;
|
||||||
command.kill?.("SIGTERM");
|
session.stop();
|
||||||
active = null;
|
active = null;
|
||||||
|
void restoreVoice();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return active;
|
return active;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
active = null;
|
active = null;
|
||||||
|
if (voiceReleased) {
|
||||||
|
await restoreVoice();
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
},
|
||||||
|
"Screen share startup failed",
|
||||||
|
);
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
error instanceof Error ? error.message : "Screen stream failed",
|
error instanceof Error ? error.message : "Screen stream failed",
|
||||||
"SCREEN_STREAM_FAILED",
|
"SCREEN_STREAM_FAILED",
|
||||||
|
|||||||
+2
-2
@@ -56,12 +56,12 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
|||||||
url,
|
url,
|
||||||
"--get-url",
|
"--get-url",
|
||||||
"--format",
|
"--format",
|
||||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
"best[protocol^=http]/best",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
]);
|
]);
|
||||||
return value.trim().split("\n")[0] || url;
|
return value.trim();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ export const wsMessagesCounter = new Counter({
|
|||||||
labelNames: ["message_type"],
|
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
|
// HTTP metrics
|
||||||
export const httpRequestDurationHistogram = new Histogram({
|
export const httpRequestDurationHistogram = new Histogram({
|
||||||
name: "http_request_duration_seconds",
|
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
|
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
||||||
const CRC_TABLE = new Uint32Array(256);
|
const CRC_TABLE = new Uint32Array(256);
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
@@ -39,4 +43,6 @@ Module.prototype.require = function (id: string) {
|
|||||||
return originalRequire.apply(this, arguments);
|
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,
|
messages,
|
||||||
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
|
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
|
||||||
try {
|
try {
|
||||||
if (!dbInitialized) {
|
try {
|
||||||
await initializeDatabase();
|
if (!dbInitialized) {
|
||||||
dbInitialized = true;
|
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];
|
const firstMessage = messages[0];
|
||||||
if (!firstMessage) return { ok: true, conversationKey, rows: [] };
|
if (!firstMessage) return { ok: true, conversationKey, rows: [] };
|
||||||
|
|
||||||
|
|||||||
@@ -26,35 +26,43 @@ export function parseModerationResponse(
|
|||||||
content: string,
|
content: string,
|
||||||
targetIds: string[],
|
targetIds: string[],
|
||||||
): AnalysisResult[] {
|
): AnalysisResult[] {
|
||||||
// Find first opening brace
|
// Find first opening brace and last closing brace
|
||||||
const startIdx = content.indexOf("{");
|
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");
|
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 parsed: unknown;
|
||||||
let lastError: Error | null = null;
|
const candidate = content.substring(startIdx, endIdx + 1);
|
||||||
|
|
||||||
for (let i = startIdx + 1; i < content.length; i++) {
|
try {
|
||||||
if (content[i] === "}") {
|
parsed = JSON.parse(candidate);
|
||||||
const candidate = content.substring(startIdx, i + 1);
|
} catch (error) {
|
||||||
try {
|
// If full substring fails, try scanning backwards from the last }
|
||||||
parsed = JSON.parse(candidate);
|
let lastError: Error =
|
||||||
// Successfully parsed, break out
|
error instanceof Error ? error : new Error(String(error));
|
||||||
break;
|
|
||||||
} catch (error) {
|
for (let i = endIdx - 1; i > startIdx; i--) {
|
||||||
// Store error and continue scanning
|
if (content[i] === "}") {
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
try {
|
||||||
continue;
|
parsed = JSON.parse(content.substring(startIdx, i + 1));
|
||||||
|
break;
|
||||||
|
} catch (innerError) {
|
||||||
|
lastError =
|
||||||
|
innerError instanceof Error
|
||||||
|
? innerError
|
||||||
|
: new Error(String(innerError));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
throw new Error(
|
throw new Error(`Failed to parse JSON: ${lastError.message}`);
|
||||||
`Failed to parse JSON: ${lastError?.message || "No valid JSON object found"}`,
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate structure
|
// Validate structure
|
||||||
@@ -72,7 +80,7 @@ export function parseModerationResponse(
|
|||||||
const targetIdSet = new Set(targetIds);
|
const targetIdSet = new Set(targetIds);
|
||||||
|
|
||||||
// Parse and validate each result
|
// 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;
|
const { message_id, status, flags, score, analysis } = result;
|
||||||
|
|
||||||
// Validate message_id exists and is in target list
|
// Validate message_id exists and is in target list
|
||||||
@@ -80,15 +88,36 @@ export function parseModerationResponse(
|
|||||||
throw new Error("Result missing 'message_id'");
|
throw new Error("Result missing 'message_id'");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetIdSet.has(message_id)) {
|
let finalId = String(message_id);
|
||||||
throw new Error(`Unknown message_id: ${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)) {
|
if (!targetIdSet.has(finalId)) {
|
||||||
throw new Error(`Duplicate message_id in results: ${message_id}`);
|
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
|
// Validate status
|
||||||
const validStatuses = ["clean", "warn", "flagged"] as const;
|
const validStatuses = ["clean", "warn", "flagged"] as const;
|
||||||
@@ -120,7 +149,7 @@ export function parseModerationResponse(
|
|||||||
const analysisStr = analysis ? String(analysis) : "";
|
const analysisStr = analysis ? String(analysis) : "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageId: message_id,
|
messageId: finalId,
|
||||||
status: status as "clean" | "warn" | "flagged",
|
status: status as "clean" | "warn" | "flagged",
|
||||||
flags: flagsArray,
|
flags: flagsArray,
|
||||||
score: numScore,
|
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
|
// Check that all target IDs were found
|
||||||
const missingIds = targetIds.filter((id) => !foundIds.has(id));
|
const missingIds = targetIds.filter((id) => !foundIds.has(id));
|
||||||
if (missingIds.length > 0) {
|
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 {
|
interface ModerationInput {
|
||||||
@@ -174,8 +208,11 @@ Context: ${contextText}
|
|||||||
Messages to analyze:
|
Messages to analyze:
|
||||||
${messagesText}
|
${messagesText}
|
||||||
|
|
||||||
For each message, respond with a JSON object containing a "results" array. Each result must have:
|
For each message, respond with a JSON object containing a "results" array.
|
||||||
- message_id: the message ID
|
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"
|
- status: "clean", "warn", or "flagged"
|
||||||
- flags: array of violation flags (e.g., ["spam", "hate_speech"])
|
- flags: array of violation flags (e.g., ["spam", "hate_speech"])
|
||||||
- score: confidence score from 0 to 1
|
- score: confidence score from 0 to 1
|
||||||
@@ -213,13 +250,44 @@ Return ONLY valid JSON, no other text.`;
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Read the response body once (either text() or json()), then reuse it.
|
||||||
if (!response.ok) {
|
let rawBody: string | undefined = undefined;
|
||||||
const text = await response.text();
|
if (typeof response.text === "function") {
|
||||||
throw new Error(`LLM API error ${response.status}: ${text}`);
|
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 {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface MediaQueueItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-3
@@ -2,17 +2,23 @@ import { Readable } from "node:stream";
|
|||||||
import {
|
import {
|
||||||
AudioPlayer,
|
AudioPlayer,
|
||||||
AudioPlayerStatus,
|
AudioPlayerStatus,
|
||||||
|
type AudioResource,
|
||||||
createAudioPlayer,
|
createAudioPlayer,
|
||||||
createAudioResource,
|
createAudioResource,
|
||||||
StreamType,
|
StreamType,
|
||||||
VoiceConnection,
|
VoiceConnection,
|
||||||
} from "@discordjs/voice";
|
} from "@discordjs/voice";
|
||||||
import type { DiscordPlayerOwner } from "./media/mediaTypes";
|
import type {
|
||||||
|
DiscordPlayOptions,
|
||||||
|
DiscordPlayerOwner,
|
||||||
|
} from "./media/mediaTypes";
|
||||||
|
|
||||||
export class DiscordPlayer {
|
export class DiscordPlayer {
|
||||||
private player: AudioPlayer;
|
private player: AudioPlayer;
|
||||||
private connection: VoiceConnection | null = null;
|
private connection: VoiceConnection | null = null;
|
||||||
private owner: DiscordPlayerOwner = "none";
|
private owner: DiscordPlayerOwner = "none";
|
||||||
|
private resource: AudioResource | null = null;
|
||||||
|
private musicVolume = 1;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.player = createAudioPlayer();
|
this.player = createAudioPlayer();
|
||||||
@@ -24,6 +30,7 @@ export class DiscordPlayer {
|
|||||||
this.player.on("error", (error) => {
|
this.player.on("error", (error) => {
|
||||||
console.error(`[player] Error: ${error.message}`);
|
console.error(`[player] Error: ${error.message}`);
|
||||||
this.owner = "none";
|
this.owner = "none";
|
||||||
|
this.resource = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,20 +47,34 @@ export class DiscordPlayer {
|
|||||||
return this.connection !== null;
|
return this.connection !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public playStream(stream: Readable, owner: DiscordPlayerOwner) {
|
public playStream(
|
||||||
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options: DiscordPlayOptions = {},
|
||||||
|
) {
|
||||||
if (owner === "none") {
|
if (owner === "none") {
|
||||||
throw new Error("Discord audio player owner is required");
|
throw new Error("Discord audio player owner is required");
|
||||||
}
|
}
|
||||||
this.assertOwnerAvailable(owner);
|
this.assertOwnerAvailable(owner);
|
||||||
|
|
||||||
const resource = createAudioResource(stream, {
|
const resource = createAudioResource(stream, {
|
||||||
inputType: StreamType.OggOpus,
|
inputType: options.inputType ?? StreamType.OggOpus,
|
||||||
|
inlineVolume: options.inlineVolume ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.owner === owner) {
|
if (this.owner === owner) {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
}
|
}
|
||||||
|
this.resource = resource;
|
||||||
this.owner = owner;
|
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.player.play(resource);
|
||||||
this.connection?.subscribe(this.player);
|
this.connection?.subscribe(this.player);
|
||||||
}
|
}
|
||||||
@@ -76,6 +97,19 @@ export class DiscordPlayer {
|
|||||||
if (!this.canControl(owner)) return;
|
if (!this.canControl(owner)) return;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.owner = "none";
|
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 {
|
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
||||||
@@ -87,6 +121,16 @@ export class DiscordPlayer {
|
|||||||
private canControl(owner?: DiscordPlayerOwner): boolean {
|
private canControl(owner?: DiscordPlayerOwner): boolean {
|
||||||
return !owner || this.owner === "none" || this.owner === owner;
|
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();
|
export const discordPlayer = new DiscordPlayer();
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ export async function startRecording(
|
|||||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||||
if (userMetadata.bot) return;
|
if (userMetadata.bot) return;
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
{ userId, username: userMetadata.username },
|
{ userId, username: userMetadata.username },
|
||||||
"Voice activity detected",
|
"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 express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import type { MediaController } from "../media/mediaController";
|
import type { MediaController } from "../media/mediaController";
|
||||||
@@ -6,57 +6,113 @@ import type { MediaMode } from "../media/mediaTypes";
|
|||||||
|
|
||||||
export type MediaRouteController = Pick<
|
export type MediaRouteController = Pick<
|
||||||
MediaController,
|
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 router = express.Router();
|
||||||
|
const { adminPassword } = options;
|
||||||
|
|
||||||
router.get("/media/status", (_req, res, next) => {
|
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
if (!adminPassword) return next();
|
||||||
res.json(controller.getState());
|
const authHeader = req.headers["x-admin-password"];
|
||||||
} catch (error) {
|
if (authHeader === adminPassword) {
|
||||||
next(error);
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Unauthorized access to admin features" });
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
router.post("/media/queue", async (req, res, next) => {
|
// Apply admin auth as router-level middleware so route stack ordering
|
||||||
try {
|
// remains predictable for tests that inspect route handlers.
|
||||||
const { source, mode = "music" } = req.body as {
|
router.use(adminAuth);
|
||||||
source?: string;
|
|
||||||
mode?: MediaMode;
|
router.get(
|
||||||
};
|
"/media/status",
|
||||||
if (!source) {
|
(_req: Request, res: Response, next: NextFunction) => {
|
||||||
throw new AppError(
|
try {
|
||||||
"Media source is required",
|
res.json(controller.getState());
|
||||||
"MISSING_MEDIA_SOURCE",
|
} catch (error) {
|
||||||
400,
|
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) => {
|
router.post(
|
||||||
try {
|
"/media/skip",
|
||||||
res.json(await controller.skip());
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
} catch (error) {
|
try {
|
||||||
next(error);
|
res.json(await controller.skip());
|
||||||
}
|
} catch (error) {
|
||||||
});
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/media/stop", async (_req, res, next) => {
|
router.post(
|
||||||
try {
|
"/media/stop",
|
||||||
res.json(await controller.stop());
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
} catch (error) {
|
try {
|
||||||
next(error);
|
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;
|
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 express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import { createChildLogger } from "../logger";
|
import { createChildLogger } from "../logger";
|
||||||
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
|
|||||||
voiceController: VoiceController;
|
voiceController: VoiceController;
|
||||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
||||||
broadcaster: ModerationBroadcaster;
|
broadcaster: ModerationBroadcaster;
|
||||||
|
adminPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVoiceRoutes(
|
export function createVoiceRoutes(
|
||||||
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
|
|||||||
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
||||||
| undefined;
|
| undefined;
|
||||||
let broadcaster: ModerationBroadcaster | undefined;
|
let broadcaster: ModerationBroadcaster | undefined;
|
||||||
|
let adminPassword: string | undefined;
|
||||||
|
|
||||||
if ("connect" in options && "disconnect" in options) {
|
if ("connect" in options && "disconnect" in options) {
|
||||||
// Old signature: just VoiceController
|
// Old signature: just VoiceController
|
||||||
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
|
|||||||
voiceController = opts.voiceController;
|
voiceController = opts.voiceController;
|
||||||
patchSharedUIState = opts.patchSharedUIState;
|
patchSharedUIState = opts.patchSharedUIState;
|
||||||
broadcaster = opts.broadcaster;
|
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
|
// GET /api/status - Get voice connection status
|
||||||
router.get("/status", (_req, res, next) => {
|
router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const status = voiceController.getStatus();
|
const status = voiceController.getStatus();
|
||||||
res.json(status);
|
res.json(status);
|
||||||
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/guilds - List available guilds
|
// GET /api/guilds - List available guilds
|
||||||
router.get("/guilds", (_req, res, next) => {
|
router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const guilds = voiceController.listGuilds();
|
const guilds = voiceController.listGuilds();
|
||||||
res.json(guilds);
|
res.json(guilds);
|
||||||
@@ -58,109 +71,111 @@ export function createVoiceRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
||||||
router.get("/guilds/:guildId/voice-channels", async (req, res, next) => {
|
router.get(
|
||||||
try {
|
"/guilds/:guildId/voice-channels",
|
||||||
const { guildId } = req.params;
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
|
||||||
if (!guildId) {
|
if (!guildId) {
|
||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
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
|
// GET /api/guilds/:guildId/channels - List text channels in a guild
|
||||||
router.get("/guilds/:guildId/channels", async (req, res, next) => {
|
router.get(
|
||||||
try {
|
"/guilds/:guildId/channels",
|
||||||
const { guildId } = req.params;
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
|
||||||
if (!guildId) {
|
if (!guildId) {
|
||||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
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
|
// POST /api/connect - Connect to a voice channel
|
||||||
router.post("/connect", async (req, res, next) => {
|
router.post(
|
||||||
try {
|
"/connect",
|
||||||
const { guildId, channelId } = req.body as {
|
adminAuth,
|
||||||
guildId?: string;
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
channelId?: string;
|
try {
|
||||||
};
|
const { guildId, channelId } = req.body as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (!guildId || !channelId) {
|
if (!guildId || !channelId) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"guildId and channelId are required",
|
"guildId and channelId are required",
|
||||||
"MISSING_CONNECT_FIELDS",
|
"MISSING_CONNECT_FIELDS",
|
||||||
400,
|
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
|
// POST /api/disconnect - Disconnect from voice channel
|
||||||
router.post("/disconnect", async (_req, res, next) => {
|
router.post(
|
||||||
try {
|
"/disconnect",
|
||||||
logger.info("Disconnecting from voice channel");
|
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
|
// Update UI state and broadcast to connected clients
|
||||||
if (patchSharedUIState && broadcaster) {
|
if (patchSharedUIState && broadcaster) {
|
||||||
const updatedState = patchSharedUIState({
|
const updatedState = patchSharedUIState({
|
||||||
selectedVoiceGuild: "",
|
selectedVoiceGuild: "",
|
||||||
selectedVoiceChannel: "",
|
selectedVoiceChannel: "",
|
||||||
});
|
});
|
||||||
broadcaster.uiState(updatedState);
|
broadcaster.uiState(updatedState);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
res.json(status);
|
);
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
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));
|
.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> {
|
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||||
if (!this.client.isReady()) {
|
if (!this.client.isReady()) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
|
|||||||
+73
-3
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Streamer } from "@dank074/discord-video-stream";
|
import { Streamer } from "./streaming";
|
||||||
import { AudioPlayerStatus } from "@discordjs/voice";
|
import { AudioPlayerStatus } from "@discordjs/voice";
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import express, {
|
import express, {
|
||||||
@@ -13,6 +13,7 @@ import express, {
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import * as prism from "prism-media";
|
import * as prism from "prism-media";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
import { config } from "./config";
|
||||||
import { AppError } from "./errors";
|
import { AppError } from "./errors";
|
||||||
import { createChildLogger, logger } from "./logger";
|
import { createChildLogger, logger } from "./logger";
|
||||||
import { MediaController } from "./media/mediaController";
|
import { MediaController } from "./media/mediaController";
|
||||||
@@ -43,6 +44,7 @@ const activeUsers = new Map<
|
|||||||
type VoiceGlobals = typeof globalThis & {
|
type VoiceGlobals = typeof globalThis & {
|
||||||
moderationBroadcaster?: ModerationBroadcaster;
|
moderationBroadcaster?: ModerationBroadcaster;
|
||||||
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
||||||
|
broadcastVideoToWeb?: (chunk: Buffer) => void;
|
||||||
updateActiveUser?: (
|
updateActiveUser?: (
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { username: string; avatar: string; speaking: boolean },
|
data: { username: string; avatar: string; speaking: boolean },
|
||||||
@@ -59,6 +61,10 @@ interface SharedUIState {
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaSettings {
|
||||||
|
musicVolume: number;
|
||||||
|
}
|
||||||
|
|
||||||
type SharedUIStatePatch = Partial<SharedUIState> & {
|
type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||||
selectedGuild?: string;
|
selectedGuild?: string;
|
||||||
};
|
};
|
||||||
@@ -73,6 +79,10 @@ const defaultSharedUIState: SharedUIState = {
|
|||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultMediaSettings: MediaSettings = {
|
||||||
|
musicVolume: 1,
|
||||||
|
};
|
||||||
|
|
||||||
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
||||||
|
|
||||||
export function normalizeSharedUIState(
|
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 {
|
function getSharedUIState(): SharedUIState {
|
||||||
return { ...sharedUIState };
|
return { ...sharedUIState };
|
||||||
}
|
}
|
||||||
@@ -121,7 +142,9 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
|
|||||||
if (typeof patch.selectedTextChannel === "string") {
|
if (typeof patch.selectedTextChannel === "string") {
|
||||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
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
|
sharedUIState.activeTab = patch.activeTab as
|
||||||
| "voice"
|
| "voice"
|
||||||
| "messages"
|
| "messages"
|
||||||
@@ -171,6 +194,7 @@ export async function startWebserver(
|
|||||||
voiceController: VoiceController,
|
voiceController: VoiceController,
|
||||||
) {
|
) {
|
||||||
await initializeSharedUIState();
|
await initializeSharedUIState();
|
||||||
|
let mediaSettings = await initializeMediaSettings();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -182,11 +206,23 @@ export async function startWebserver(
|
|||||||
// Create broadcaster instance
|
// Create broadcaster instance
|
||||||
const broadcaster = createBroadcaster();
|
const broadcaster = createBroadcaster();
|
||||||
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
||||||
|
(globalThis as any).ADMIN_PASSWORD = config.ADMIN_PASSWORD;
|
||||||
|
|
||||||
const streamer = new Streamer(_client);
|
const streamer = new Streamer(_client);
|
||||||
const screenController = createScreenShareController({
|
const screenController = createScreenShareController({
|
||||||
getVoiceStatus: () => voiceController.getStatus(),
|
getVoiceStatus: () => voiceController.getStatus(),
|
||||||
streamer,
|
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({
|
const mediaController = new MediaController({
|
||||||
@@ -194,6 +230,11 @@ export async function startWebserver(
|
|||||||
isBrowserStreaming: () => sharedUIState.isStreaming,
|
isBrowserStreaming: () => sharedUIState.isStreaming,
|
||||||
screenController,
|
screenController,
|
||||||
onStateChange: (state) => broadcaster.mediaState(state),
|
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.
|
// 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());
|
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
|
// Register route modules
|
||||||
app.use(
|
app.use(
|
||||||
"/api",
|
"/api",
|
||||||
@@ -268,12 +319,18 @@ export async function startWebserver(
|
|||||||
voiceController,
|
voiceController,
|
||||||
patchSharedUIState,
|
patchSharedUIState,
|
||||||
broadcaster,
|
broadcaster,
|
||||||
|
adminPassword: config.ADMIN_PASSWORD,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("/api", createMessageRoutes());
|
app.use("/api", createMessageRoutes());
|
||||||
app.use("/api", createAnalysisRoutes());
|
app.use("/api", createAnalysisRoutes());
|
||||||
app.use("/api", createSyncRoutes(_client));
|
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
|
// Inbound: Discord PCM → tagged chunks → browser
|
||||||
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
|
(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 = (
|
(globalThis as VoiceGlobals).updateActiveUser = (
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { username: string; avatar: string; speaking: boolean },
|
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