Compare commits
2 Commits
eda32720c8
...
0c7930bd01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7930bd01 | ||
|
|
e22e620bae |
@@ -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();
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
33
test_dank.ts
Normal 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
27
test_dank2.ts
Normal 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
BIN
test_out.nut
Normal file
Binary file not shown.
18
test_stream.ts
Normal file
18
test_stream.ts
Normal 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();
|
||||||
@@ -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" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user