Compare commits
8
Commits
c0f66c78a3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a2d9abc6 | ||
|
|
d33942cc23 | ||
|
|
aa8d5dfd41 | ||
|
|
8584030633 | ||
|
|
f35710db3f | ||
|
|
0c7930bd01 | ||
|
|
e22e620bae | ||
|
|
eda32720c8 |
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.test
|
||||
.git
|
||||
.github
|
||||
recordings
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
test_out.nut
|
||||
@@ -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)"
|
||||
+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,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
|
||||
@@ -23,6 +23,7 @@
|
||||
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dank074/discord-video-stream": "^6.0.0",
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
|
||||
Generated
+38
-628
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import { createMusicPlayer } from "./musicPlayer";
|
||||
export interface MediaControllerDependencies {
|
||||
isVoiceConnected?: () => boolean;
|
||||
isBrowserStreaming?: () => boolean;
|
||||
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
||||
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
|
||||
musicPlayer?: MusicPlayer;
|
||||
screenController?: ScreenShareController;
|
||||
onStateChange?: (state: MediaState) => void;
|
||||
@@ -73,12 +73,17 @@ export class MediaController {
|
||||
options: QueueMediaOptions = {},
|
||||
): Promise<MediaState> {
|
||||
const mode = options.mode ?? "music";
|
||||
|
||||
const resolved = await (
|
||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||
)(source, mode);
|
||||
|
||||
if (mode === "screen") {
|
||||
// Stop current music if any
|
||||
this.playbackToken++;
|
||||
this.playback?.stop();
|
||||
this.playback = null;
|
||||
return this.startScreen(source);
|
||||
return this.startScreen(resolved.source);
|
||||
}
|
||||
|
||||
// mode === "music"
|
||||
@@ -95,9 +100,6 @@ export class MediaController {
|
||||
}
|
||||
|
||||
this.assertCanStartMusic();
|
||||
const resolved = await (
|
||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||
)(source);
|
||||
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||
this.startNextIfIdle();
|
||||
return this.emitState();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppError } from "../errors";
|
||||
import type { ResolvedMediaSource } from "./mediaTypes";
|
||||
import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
|
||||
import { createPlayDlResolver } from "./playDlResolver";
|
||||
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
||||
|
||||
@@ -18,7 +18,10 @@ export function createMediaResolver(
|
||||
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
||||
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
||||
|
||||
return async function resolve(input: string): Promise<ResolvedMediaSource> {
|
||||
return async function resolve(
|
||||
input: string,
|
||||
mode: MediaMode = "music"
|
||||
): Promise<ResolvedMediaSource> {
|
||||
const source = input.trim();
|
||||
if (!source) {
|
||||
throw new AppError(
|
||||
@@ -31,13 +34,17 @@ export function createMediaResolver(
|
||||
const url = parseUrl(source);
|
||||
if (url && isYouTubeUrl(url)) {
|
||||
const metadata = await ytdlp.getMetadata(source);
|
||||
const directUrl = await ytdlp.getDirectAudioUrl(source);
|
||||
const directUrl = mode === "screen"
|
||||
? await ytdlp.getDirectVideoUrl(source)
|
||||
: await ytdlp.getDirectAudioUrl(source);
|
||||
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
||||
}
|
||||
|
||||
if (url && isSpotifyTrackUrl(url)) {
|
||||
const result = await playDlResolver.resolveSpotifyTrack(source);
|
||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
||||
const directUrl = mode === "screen"
|
||||
? await ytdlp.getDirectVideoUrl(result.url)
|
||||
: await ytdlp.getDirectAudioUrl(result.url);
|
||||
return { source: directUrl, title: result.title, kind: "spotify" };
|
||||
}
|
||||
|
||||
@@ -55,7 +62,9 @@ export function createMediaResolver(
|
||||
|
||||
if (!url && !looksLikeUrl(source)) {
|
||||
const result = await playDlResolver.searchYouTube(source);
|
||||
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
||||
const directUrl = mode === "screen"
|
||||
? await ytdlp.getDirectVideoUrl(result.url)
|
||||
: await ytdlp.getDirectAudioUrl(result.url);
|
||||
return { source: directUrl, title: result.title, kind: "search" };
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||
"best[protocol^=http]/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
|
||||
+94
-189
@@ -2,28 +2,17 @@ import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { prepareTranscoder, TranscoderOptions } from "./transcoder";
|
||||
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 = {
|
||||
channel: {
|
||||
id: string;
|
||||
};
|
||||
createStreamConnection: () => Promise<StreamConnectionLike>;
|
||||
disconnect?: () => void;
|
||||
};
|
||||
|
||||
type StreamConnectionLike = {
|
||||
playVideo: (resource: string | Readable, options?: Record<string, unknown>) => DispatcherLike;
|
||||
playAudio: (resource: string | Readable, options?: Record<string, unknown>) => DispatcherLike;
|
||||
disconnect?: () => void;
|
||||
};
|
||||
|
||||
type DispatcherLike = EventEmitter & {
|
||||
stop?: () => void;
|
||||
pause?: () => void;
|
||||
resume?: () => void;
|
||||
};
|
||||
type VoiceConnectionLike = any;
|
||||
type StreamConnectionLike = any;
|
||||
|
||||
export interface StreamPlayOptions {
|
||||
fps?: number;
|
||||
@@ -39,149 +28,111 @@ export interface StreamSession {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export const Encoders = {
|
||||
software: (opts: any) => opts,
|
||||
};
|
||||
|
||||
export const Utils = {
|
||||
export const UtilsAPI = {
|
||||
normalizeVideoCodec: (c: string) => c.toUpperCase?.() ?? c,
|
||||
};
|
||||
|
||||
export class Streamer {
|
||||
client: Client;
|
||||
dankStreamer: DankStreamer;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async joinVoice(guildId: string, channelId: string): Promise<VoiceConnectionLike> {
|
||||
const guild = this.client.guilds.cache.get(guildId);
|
||||
const channel = (guild?.channels.cache.get(channelId) ??
|
||||
(await guild?.channels.fetch(channelId).catch(() => null))) as any;
|
||||
if (!channel || channel.guild?.id !== guildId) {
|
||||
throw new Error("VOICE_CHANNEL_NOT_FOUND");
|
||||
}
|
||||
|
||||
const existingConnection = (this.client.voice as any).connection as
|
||||
| VoiceConnectionLike
|
||||
| undefined;
|
||||
if (existingConnection?.channel?.id === channelId) {
|
||||
(existingConnection as any).setVideoCodec?.("H264");
|
||||
return existingConnection;
|
||||
}
|
||||
|
||||
const voiceConnection = (await this.client.voice.joinChannel(channel as any, {
|
||||
selfMute: true,
|
||||
selfDeaf: true,
|
||||
selfVideo: false,
|
||||
videoCodec: "H264",
|
||||
})) as unknown as VoiceConnectionLike;
|
||||
|
||||
(voiceConnection as any).setVideoCodec?.("H264");
|
||||
|
||||
return voiceConnection;
|
||||
this.dankStreamer = new DankStreamer(client);
|
||||
}
|
||||
|
||||
async createSession(guildId: string, channelId: string): Promise<StreamSession> {
|
||||
const connection = await this.joinVoice(guildId, channelId);
|
||||
const stream = await connection.createStreamConnection();
|
||||
await this.dankStreamer.joinVoice(guildId, channelId);
|
||||
|
||||
let activeVideo: DispatcherLike | null = null;
|
||||
let activeAudio: DispatcherLike | null = null;
|
||||
let finished = false;
|
||||
let stopped = false;
|
||||
let currentCommand: any = null;
|
||||
|
||||
const stop = () => {
|
||||
activeVideo?.stop?.();
|
||||
activeAudio?.stop?.();
|
||||
stream.disconnect?.();
|
||||
connection.disconnect?.();
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
if (currentCommand?.kill) currentCommand.kill("SIGKILL");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.dankStreamer.stopStream();
|
||||
this.dankStreamer.leaveVoice();
|
||||
};
|
||||
|
||||
const waitForFinish = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const maybeResolve = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
stop();
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
activeVideo?.on("finish", maybeResolve);
|
||||
activeAudio?.on("finish", maybeResolve);
|
||||
activeVideo?.on("error", handleError);
|
||||
activeAudio?.on("error", handleError);
|
||||
});
|
||||
|
||||
return {
|
||||
connection,
|
||||
stream,
|
||||
async play(source: string | Readable, options: StreamPlayOptions = {}) {
|
||||
const videoOptions: Record<string, any> = {
|
||||
fps: options.fps ?? 30,
|
||||
bitrate: options.bitrate ?? 2500,
|
||||
presetH26x: options.presetH26x ?? "superfast",
|
||||
};
|
||||
|
||||
const audioOptions: Record<string, any> = {
|
||||
volume: false,
|
||||
};
|
||||
|
||||
let videoSource: string | Readable;
|
||||
let audioSource: string | Readable;
|
||||
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")) {
|
||||
// yt-dlp returns multiple URLs (e.g., video\n audio\n)
|
||||
const urls = source.split("\n").filter((u) => u.trim());
|
||||
videoSource = urls[0] ?? source;
|
||||
audioSource = urls[1] ?? urls[0] ?? source;
|
||||
} else if (typeof source !== "string") {
|
||||
// If source is a Readable (e.g. ffmpeg stdout) and audio+video
|
||||
// need to be played separately, tee the stream into two PassThroughs.
|
||||
if (options.includeAudio !== false) {
|
||||
const videoTee = new PassThrough();
|
||||
const audioTee = new PassThrough();
|
||||
// Pipe to both tees; allow consumers to read independently.
|
||||
(source as Readable).pipe(videoTee);
|
||||
(source as Readable).pipe(audioTee);
|
||||
videoSource = videoTee;
|
||||
audioSource = audioTee;
|
||||
} else {
|
||||
// audio excluded — single video stream
|
||||
const videoTee = new PassThrough();
|
||||
(source as Readable).pipe(videoTee);
|
||||
videoSource = videoTee;
|
||||
audioSource = videoTee;
|
||||
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
|
||||
}
|
||||
} else {
|
||||
videoSource = source;
|
||||
audioSource = source;
|
||||
}
|
||||
};
|
||||
webOutput.on("data", onData);
|
||||
|
||||
const inputFFmpegArgs = [
|
||||
"-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",
|
||||
];
|
||||
|
||||
if (typeof videoSource === "string" && videoSource.startsWith("http")) {
|
||||
videoOptions.inputFFmpegArgs = inputFFmpegArgs;
|
||||
}
|
||||
if (typeof audioSource === "string" && audioSource.startsWith("http")) {
|
||||
audioOptions.inputFFmpegArgs = inputFFmpegArgs;
|
||||
}
|
||||
|
||||
activeVideo = stream.playVideo(videoSource, videoOptions);
|
||||
if (options.includeAudio !== false) {
|
||||
activeAudio = stream.playAudio(audioSource, audioOptions);
|
||||
}
|
||||
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 {
|
||||
await waitForFinish();
|
||||
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();
|
||||
}
|
||||
},
|
||||
@@ -190,33 +141,12 @@ export class Streamer {
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareStream(source: string, _options: any): {
|
||||
command: ChildProcess | { kill?: (signal: NodeJS.Signals) => unknown };
|
||||
output: Readable;
|
||||
} {
|
||||
const opts: TranscoderOptions = {
|
||||
fps: _options?.fps ?? 30,
|
||||
bitrate: _options?.bitrate ?? "2500k",
|
||||
preset: _options?.presetH26x ?? _options?.preset ?? "superfast",
|
||||
};
|
||||
const { command, output } = prepareTranscoder(source, opts);
|
||||
return { command, output };
|
||||
export function prepareStream(source: string, _options: any): any {
|
||||
return { command: null, output: new PassThrough() };
|
||||
}
|
||||
|
||||
export async function playStream(
|
||||
output: Readable,
|
||||
_streamer: Streamer,
|
||||
_options?: object,
|
||||
): Promise<void> {
|
||||
// Simple implementation: consume the stream until end. In production
|
||||
// this should attach the stream to a WebRTC connection for Discord.
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
output.on("end", resolve);
|
||||
output.on("close", resolve);
|
||||
output.on("error", (err) => reject(err));
|
||||
// Ensure data flows
|
||||
if (output.readable) output.resume();
|
||||
});
|
||||
export async function playStream(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function createStreamSession(
|
||||
@@ -232,7 +162,6 @@ export async function playPreparedStream(
|
||||
session: StreamSession,
|
||||
options: StreamPlayOptions = {},
|
||||
): Promise<void> {
|
||||
// Default behavior: forward resource (string or Readable) to session.play.
|
||||
await session.play(source, options);
|
||||
}
|
||||
|
||||
@@ -241,29 +170,5 @@ export async function playTranscodedPreparedStream(
|
||||
session: StreamSession,
|
||||
options: StreamPlayOptions = {},
|
||||
): Promise<void> {
|
||||
if (typeof source === "string" && /^(https?:)?\/\//.test(source)) {
|
||||
const { command, output } = prepareStream(source, options);
|
||||
const globalAny: any = globalThis;
|
||||
const onData = (chunk: Buffer) => {
|
||||
try {
|
||||
globalAny.broadcastVideoToWeb?.(chunk);
|
||||
} catch {
|
||||
// ignore errors broadcasting
|
||||
}
|
||||
};
|
||||
output.on("data", onData);
|
||||
try {
|
||||
await session.play(output, options);
|
||||
} finally {
|
||||
output.off("data", onData);
|
||||
try {
|
||||
command.kill?.("SIGKILL");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await session.play(source, options);
|
||||
}
|
||||
|
||||
+3
-1
@@ -213,8 +213,10 @@ export async function startWebserver(
|
||||
getVoiceStatus: () => voiceController.getStatus(),
|
||||
streamer,
|
||||
useTranscoder: true,
|
||||
onBeforeStreamStart: async () => {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Binary file not shown.
@@ -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();
|
||||
@@ -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 screenController: ScreenShareController = {
|
||||
isActive: vi.fn(() => false),
|
||||
@@ -209,7 +209,7 @@ describe("MediaController", () => {
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
};
|
||||
const resolveMediaSource = vi.fn(async (input) => source(input));
|
||||
const resolveMediaSource = vi.fn(async (input, mode) => source(input));
|
||||
const controller = new MediaController({
|
||||
isVoiceConnected: () => true,
|
||||
isBrowserStreaming: () => false,
|
||||
@@ -225,7 +225,7 @@ describe("MediaController", () => {
|
||||
expect(screenController.start).toHaveBeenCalledWith(
|
||||
"https://youtu.be/video",
|
||||
);
|
||||
expect(resolveMediaSource).not.toHaveBeenCalled();
|
||||
expect(resolveMediaSource).toHaveBeenCalledWith("https://youtu.be/video", "screen");
|
||||
expect(state).toMatchObject({ playing: true, activeMode: "screen" });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user