Compare commits

..
2 Commits
9 changed files with 134 additions and 30 deletions
+7 -5
View File
@@ -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();
+14 -5
View File
@@ -1,7 +1,7 @@
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import { AppError } from "../errors";
import type { ResolvedMediaSource } from "./mediaTypes";
import type { ResolvedMediaSource, MediaMode } from "./mediaTypes";
import { createPlayDlResolver } from "./playDlResolver";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
@@ -18,7 +18,10 @@ export function createMediaResolver(
const ytdlp = dependencies.ytdlp ?? createYtDlp();
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
return async function resolve(input: string): Promise<ResolvedMediaSource> {
return async function resolve(
input: string,
mode: MediaMode = "music"
): Promise<ResolvedMediaSource> {
const source = input.trim();
if (!source) {
throw new AppError(
@@ -31,13 +34,17 @@ export function createMediaResolver(
const url = parseUrl(source);
if (url && isYouTubeUrl(url)) {
const metadata = await ytdlp.getMetadata(source);
const directUrl = await ytdlp.getDirectAudioUrl(source);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(source)
: await ytdlp.getDirectAudioUrl(source);
return { source: directUrl, title: metadata.title, kind: "youtube" };
}
if (url && isSpotifyTrackUrl(url)) {
const result = await playDlResolver.resolveSpotifyTrack(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(result.url)
: await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "spotify" };
}
@@ -55,7 +62,9 @@ export function createMediaResolver(
if (!url && !looksLikeUrl(source)) {
const result = await playDlResolver.searchYouTube(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
const directUrl = mode === "screen"
? await ytdlp.getDirectVideoUrl(result.url)
: await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "search" };
}
+1 -1
View File
@@ -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",
+31 -16
View File
@@ -71,21 +71,24 @@ export class Streamer {
targetSource = urls[0] ?? source;
}
const fps = options.fps ?? 30;
const bitrateStr = String(options.bitrate ?? 2500).replace(/k$/i, "");
const bitrateVideo = parseInt(bitrateStr, 10) || 2500;
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) ?? "superfast" },
x265: { preset: (options.presetH26x as any) ?? "superfast" },
x264: { preset: (options.presetH26x as any) ?? "ultrafast" },
x265: { preset: (options.presetH26x as any) ?? "ultrafast" },
}),
videoCodec: Utils.normalizeVideoCodec("H264"),
width: 1280,
height: 720,
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",
@@ -95,6 +98,12 @@ export class Streamer {
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 {
@@ -103,21 +112,27 @@ export class Streamer {
// ignore
}
};
output.on("data", onData);
webOutput.on("data", onData);
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 {
await dankPlayStream(output, this.dankStreamer, {
type: "go-live",
width: 1280,
height: 720,
frameRate: fps,
});
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 {
output.off("data", onData);
console.log("[Streamer] Cleaning up stream resources.");
webOutput.off("data", onData);
stop();
}
},
+33
View File
File diff suppressed because one or more lines are too long
+27
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
View File
Binary file not shown.
+18
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();
+3 -3
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 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" });
});