Compare commits

..

4 Commits

14 changed files with 291 additions and 30 deletions

11
.dockerignore Normal file
View File

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

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

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

36
Dockerfile Normal file
View File

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

45
deploy.sh Executable file
View File

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

30
docker-compose.yml Normal file
View File

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

View File

@@ -17,7 +17,7 @@ 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;
@@ -73,12 +73,17 @@ export class MediaController {
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") {
// Stop current music if any // Stop current music if any
this.playbackToken++; this.playbackToken++;
this.playback?.stop(); this.playback?.stop();
this.playback = null; this.playback = null;
return this.startScreen(source); return this.startScreen(resolved.source);
} }
// mode === "music" // mode === "music"
@@ -95,9 +100,6 @@ export class MediaController {
} }
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();

View File

@@ -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" };
} }

View File

@@ -56,7 +56,7 @@ 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",

View File

@@ -71,21 +71,24 @@ export class Streamer {
targetSource = urls[0] ?? source; targetSource = urls[0] ?? source;
} }
const fps = options.fps ?? 30; const fps = options.fps ?? 60;
const bitrateStr = String(options.bitrate ?? 2500).replace(/k$/i, ""); const bitrateStr = String(options.bitrate ?? 8000).replace(/k$/i, "");
const bitrateVideo = parseInt(bitrateStr, 10) || 2500; 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, { const { command, output } = dankPrepareStream(targetSource, {
encoder: Encoders.software({ encoder: Encoders.software({
x264: { preset: (options.presetH26x as any) ?? "superfast" }, x264: { preset: (options.presetH26x as any) ?? "ultrafast" },
x265: { preset: (options.presetH26x as any) ?? "superfast" }, x265: { preset: (options.presetH26x as any) ?? "ultrafast" },
}), }),
videoCodec: Utils.normalizeVideoCodec("H264"), videoCodec: Utils.normalizeVideoCodec("H264"),
width: 1280, width: 1920,
height: 720, height: 1080,
bitrateVideo: bitrateVideo, bitrateVideo: bitrateVideo,
frameRate: fps, frameRate: fps,
includeAudio: options.includeAudio !== false, includeAudio: options.includeAudio !== false,
minimizeLatency: false,
customInputOptions: ["-fflags nobuffer"],
customHeaders: { customHeaders: {
"User-Agent": "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", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3",
@@ -95,6 +98,12 @@ export class Streamer {
currentCommand = command; currentCommand = command;
const webOutput = new PassThrough();
const discordOutput = new PassThrough();
output.pipe(webOutput);
output.pipe(discordOutput);
const globalAny: any = globalThis; const globalAny: any = globalThis;
const onData = (chunk: Buffer) => { const onData = (chunk: Buffer) => {
try { try {
@@ -103,21 +112,27 @@ export class Streamer {
// ignore // ignore
} }
}; };
output.on("data", onData); webOutput.on("data", onData);
command.on("error", (err: Error) => { command.on("error", (err: Error) => {
console.error("Transcoder error:", err); 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 { try {
await dankPlayStream(output, this.dankStreamer, { console.log("[Streamer] Calling dankPlayStream...");
type: "go-live", await dankPlayStream(discordOutput, this.dankStreamer, undefined);
width: 1280, console.log("[Streamer] dankPlayStream completed successfully.");
height: 720, } catch (err) {
frameRate: fps, console.error("[Streamer] dankPlayStream error:", err);
});
} finally { } finally {
output.off("data", onData); console.log("[Streamer] Cleaning up stream resources.");
webOutput.off("data", onData);
stop(); stop();
} }
}, },

33
test_dank.ts Normal file
View File

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

27
test_dank2.ts Normal file
View File

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

BIN
test_out.nut Normal file

Binary file not shown.

18
test_stream.ts Normal file
View File

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

View File

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