feat(gateway): restore Discord GoLive screenshare (dulu pernah ada, hilang saat split microservices)

User: 'dulu sharescreen juga bisa'. Terbukti: commit d50ce86 (Mei 2026)
punya src/media/screenShareController.ts + vendor @dank074/discord-video-stream,
hilang saat rombak monolith -> microservices. Interface ScreenShareController
masih ada di mediaTypes.ts tapi implementasinya tidak.

Restore:
- dep @dank074/discord-video-stream@6.0.0 (npm, dibangun untuk
  discord.js-selfbot-v13 — cocok dengan stack gateway)
- mediaSource.getDirectVideoUrl (yt-dlp --get-url bestvideo+bestaudio)
- screenShareController.ts (BARU): Streamer(client) + prepareStream H264
  720p30 + playStream go-live; owner check via discordPlayer
- media.handler: mode:'screen' di media:queue -> screen path; status
  expose activeMode; stop matiin screen
- FE: tombol Screen di MusicPlayer + hook useMediaQueue({url, mode})

Verifikasi: gateway tsc PASS, FE tsc PASS, biome 0 error, next build PASS.
Nix build pending (dep native @lng2004/node-datachannel butuh pnpm rebuild).
This commit is contained in:
asepharyana
2026-08-01 11:36:40 +07:00
parent 189ab1c1f6
commit 891c1305f0
9 changed files with 5460 additions and 6 deletions
@@ -282,6 +282,87 @@ export function resolveMediaUrl(
});
}
/**
* Resolve a media URL to a directly playable video URL (for screen share /
* GoLive streaming). Uses yt-dlp `--get-url` with bestvideo+bestaudio.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero code.
*/
export function getDirectVideoUrl(url: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const args = [
url,
"--get-url",
"--format",
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
"--no-playlist",
"--no-warnings",
"--quiet",
];
logger.info({ url }, "Spawning yt-dlp for direct video URL");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
});
activeProcesses.add(proc);
let stdoutBuf = "";
let stderrBuf = "";
const MAX_STDERR = 4096;
const MAX_STDOUT = 1_048_576;
if (proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => {
if (stdoutBuf.length < MAX_STDOUT) {
stdoutBuf += chunk
.toString("utf8")
.slice(0, MAX_STDOUT - stdoutBuf.length);
}
});
}
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
if (stderrBuf.length < MAX_STDERR) {
stderrBuf += chunk
.toString("utf8")
.slice(0, MAX_STDERR - stderrBuf.length);
}
});
}
proc.on("error", (err: NodeJS.ErrnoException) => {
activeProcesses.delete(proc);
if (err.code === "ENOENT") {
reject(buildNotInstalledError());
} else {
reject(new Error(`yt-dlp failed to start: ${err.message}`));
}
});
proc.on("close", (code) => {
activeProcesses.delete(proc);
if (code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
reject(
new Error(`yt-dlp direct URL resolution exited with code ${code}${detail}`),
);
return;
}
const firstLine = stdoutBuf.trim().split("\n")[0];
if (!firstLine) {
reject(new Error("yt-dlp returned no direct video URL"));
return;
}
resolve(firstLine);
});
});
}
/**
* Extract metadata (title, duration, thumbnail) from a media URL
* without downloading the audio stream.
@@ -0,0 +1,105 @@
import {
Encoders,
prepareStream,
playStream,
Streamer,
Utils,
} from "@dank074/discord-video-stream";
import type { Client } from "discord.js-selfbot-v13";
import { createChildLogger } from "@/shared/logger/index";
import type { ScreenSharePlayback } from "./mediaTypes.js";
import { getDirectVideoUrl } from "./mediaSource.js";
import { discordPlayer } from "./player.js";
const logger = createChildLogger("screen-share");
export interface ScreenShareVoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
}
/**
* Discord Go Live (screenshare) via @dank074/discord-video-stream.
*
* Pipeline:
* URL (YouTube, dll.) → yt-dlp direct video URL → ffmpeg (H264 720p30) →
* playStream({ type: "go-live" }) → Discord voice channel as Go Live.
*
* Restored from the pre-microservices implementation (commit d50ce86,
* src/media/screenShareController.ts) — the interface survived in
* mediaTypes.ts but the implementation was lost during the split.
*/
export class ScreenShareController {
private logger = createChildLogger("screen-share");
private streamer: Streamer | null = null;
private active: ScreenSharePlayback | null = null;
constructor(
private readonly client: Client,
private readonly getVoiceStatus: () => ScreenShareVoiceStatus,
) {}
isActive(): boolean {
return this.active !== null;
}
async start(source: string): Promise<ScreenSharePlayback> {
const status = this.getVoiceStatus();
if (
!status.connected ||
!status.activeGuildId ||
!status.activeChannelId
) {
throw new Error("Connect to a voice channel before sharing screen");
}
if (this.active || discordPlayer.getOwner() !== "none") {
throw new Error("Another media mode is active");
}
try {
const directUrl = await getDirectVideoUrl(source);
if (!this.streamer) {
this.streamer = new Streamer(this.client);
}
const { command, output } = prepareStream(directUrl, {
encoder: Encoders.software({ x264: { preset: "superfast" } }),
width: 1280,
height: 720,
frameRate: 30,
bitrateVideo: 2500,
bitrateVideoMax: 4000,
includeAudio: true,
videoCodec: Utils.normalizeVideoCodec("H264"),
});
let stopped = false;
const done = playStream(output, this.streamer, {
type: "go-live",
}).finally(() => {
this.active = null;
});
const controller = this;
this.active = {
done,
stop: () => {
if (stopped) return;
stopped = true;
command.kill("SIGTERM");
controller.active = null;
},
};
logger.info({ source }, "Screen share started");
return this.active;
} catch (error) {
this.active = null;
const message = error instanceof Error ? error.message : String(error);
logger.error({ error: message, source }, "Screen stream failed");
throw error;
}
}
}