refactor(gateway): remove screen-share / GoLive feature entirely

Drop the Discord Go Live (screen share) stack across the discord-gateway:
- delete src/goLive/ (19 modules: Streamer, Demuxer, encoders, WebRTC wrapper, native loader, etc.)
- delete native/libdatachannel-min/ N-API binding + flake native build + LD_LIBRARY_PATH wiring
- delete screenShareController.ts and screen-share tests (goLive-port, golive-*, demuxerNut, screenShareInput)
- mediaSource.ts: remove Invidious helpers + downloadScreenInput (YouTube full-file download)
- mediaTypes.ts: drop ScreenShare* types, narrow MediaMode to 'music' and DiscordPlayerOwner to non-screen
- media.handler.ts: remove screen branch, screenController/screenPlayback, voice-disconnect/reconnect accessor
- commandHandler.ts: stop passing getVoiceStatus / setVoiceController into MediaHandler
- media handler now only handles music; music queue/playback/status untouched

Verification: tsc --noEmit clean, biome clean on touched files, no lingering goLive/screenShare refs in BE/FE/gateway.
This commit is contained in:
asepharyana
2026-08-15 21:20:20 +07:00
parent 9ae26b8ec9
commit 0164444dd7
42 changed files with 14 additions and 5957 deletions
@@ -83,12 +83,7 @@ export class CommandHandler {
// Create domain-specific handlers with their dependencies
this.voiceHandler = new VoiceHandler(client, voiceController);
this.mediaHandler = new MediaHandler(client, () =>
voiceController.getStatus(),
);
// Give media handler access to disconnect/reconnect voice around screen
// share (GoLive needs its own WebRTC connection).
this.mediaHandler.setVoiceController(() => voiceController);
this.mediaHandler = new MediaHandler();
this.guildHandler = new GuildHandler(client);
this.moderationHandler = new ModerationHandler(client);
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import { StreamType } from "@discordjs/voice";
import type { Client } from "discord.js-selfbot-v13";
import type { CommandMessage, CommandReply } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import {
@@ -13,10 +12,6 @@ import type {
MediaQueueItem,
} from "../voice-recording/mediaTypes.js";
import { discordPlayer } from "../voice-recording/player.js";
import {
ScreenShareController,
type ScreenShareVoiceStatus,
} from "../voice-recording/screenShareController.js";
import { setMediaStatusKey } from "./mediaStatusSink.js";
// ---------------------------------------------------------------------------
@@ -84,17 +79,8 @@ function buildStatusPayload(): MediaStatusPayload {
export class MediaHandler {
private logger = createChildLogger("media-handler");
private screenController: ScreenShareController | null = null;
private screenPlayback: { stop(): void } | null = null;
constructor(
private readonly client: Client | null = null,
private readonly getVoiceStatus: () => ScreenShareVoiceStatus = () => ({
connected: false,
activeGuildId: null,
activeChannelId: null,
}),
) {
constructor() {
// Register auto-advance on natural track end. advanceQueue mutates the
// module-level currentTrackItem/queue, so we must re-publish the status
// key afterward: otherwise the backend's Redis `media:status` cache (and
@@ -108,31 +94,11 @@ export class MediaHandler {
});
}
/**
* Give MediaHandler access to the VoiceController so screen-share can
* disconnect/reconnect the @discordjs audio connection around a GoLive
* stream (Discord allows only one voice session per user).
*/
private voiceControllerAccessor:
| (() => {
disconnectGuild(guildId: string): Promise<void>;
connect(guildId: string, channelId: string): Promise<unknown>;
getStatus(): {
activeGuildId: string | null;
activeChannelId: string | null;
};
})
| null = null;
setVoiceController(accessor: typeof this.voiceControllerAccessor): void {
this.voiceControllerAccessor = accessor;
}
/**
* Persist the latest media state to Redis so the backend/frontend see queue
* advances that happen outside a command (natural track end, screen-share
* done). CommandHandler owns the Redis status-key writes for command-triggered
* changes; this covers the side-effect-only path.
* advances that happen outside a command (natural track end). CommandHandler
* owns the Redis status-key writes for command-triggered changes; this
* covers the side-effect-only path.
*/
private publishStatus(): void {
try {
@@ -152,7 +118,6 @@ export class MediaHandler {
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
// Accept both `url` (canonical) and `source` (legacy FE) for resilience.
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music";
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
if (!url) {
@@ -175,74 +140,6 @@ export class MediaHandler {
};
}
// Screen share (GoLive) path — bypasses the audio queue entirely.
if (mode === "screen") {
try {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway client not initialized",
};
}
if (!this.screenController) {
this.screenController = new ScreenShareController(
this.client,
this.getVoiceStatus,
// releaseVoice — disconnect the @discordjs/voice connection so the
// dank074 Streamer can take over (Discord: one voice session/user).
async (status) => {
const vc = this.voiceControllerAccessor?.();
const guildId = status.activeGuildId ?? null;
if (vc && guildId) {
await vc.disconnectGuild(guildId);
}
},
// restoreVoice — reconnect the @discordjs audio connection after
// the stream ends so mic/listen keep working.
async (status) => {
const vc = this.voiceControllerAccessor?.();
if (vc && status.activeGuildId && status.activeChannelId) {
await vc.connect(status.activeGuildId, status.activeChannelId);
}
},
);
}
const playback = await this.screenController.start(url);
this.screenPlayback = playback;
currentTrackItem = {
id: randomUUID(),
source: url,
title: url,
kind: "url",
mode: "screen",
requestedBy,
addedAt: Date.now(),
status: "playing",
};
playback.done
.catch((err) => {
this.logger.error(
{ error: err instanceof Error ? err.message : String(err) },
"Screen playback promise rejected",
);
})
.finally(() => {
this.screenPlayback = null;
if (currentTrackItem?.mode === "screen") {
currentTrackItem = null;
}
});
this.logger.info({ url }, "Screen share started");
return { id: cmd.id, success: true, data: buildStatusPayload() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error({ error: message }, "Screen share failed to start");
return { id: cmd.id, success: false, data: null, error: message };
}
}
// Lightweight metadata fetch for display — the full resolve happens in playNext
let title: string = url;
let duration: number | undefined;
@@ -264,7 +161,7 @@ export class MediaHandler {
source: url,
title,
kind: "url" as const,
mode,
mode: "music",
requestedBy,
addedAt: Date.now(),
status: "queued",
@@ -308,12 +205,6 @@ export class MediaHandler {
}
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
// Stop screen share if active — playback.done.finally clears the item.
this.screenPlayback?.stop();
this.screenPlayback = null;
if (currentTrackItem?.mode === "screen") {
currentTrackItem = null;
}
discordPlayer.stop("music");
currentTrackItem = null;
mediaQueue.length = 0; // Clear entire queue
@@ -394,7 +285,7 @@ export class MediaHandler {
// Music playback: transcode once to high-quality OggOpus (48kHz stereo,
// 192kbps) with volume baked into the encode. This avoids the double
// lossy encode that inlineVolume would cause and gives Discord the
// cleanest possible stream. Screen share bypasses this entirely.
// cleanest possible stream.
const transcoded = transcodeToHighQualityOgg(
resolution.stream,
discordPlayer.getMusicVolume(),
@@ -1,14 +1,5 @@
import { type ChildProcess, spawn } from "node:child_process";
import {
chmodSync,
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, type Readable } from "node:stream";
@@ -232,7 +223,7 @@ function buildNotInstalledError(): Error {
* in account's cookies. The path is configurable via GMW_YT_COOKIES_PATH
* (default: the BWS-provided file the deploy writes to /etc/.../ytcookies.txt).
* If the file doesn't exist we pass nothing and fall back to anon (YouTube
* may 403 — screen share will fail gracefully, not crash).
* may 403 — playback will fail gracefully, not crash).
*/
var _cachedCookiePath: string | null = null;
function buildCookieArgs(): string[] {
@@ -267,7 +258,7 @@ function buildCookieArgs(): string[] {
// Never hand the ORIGINAL system file to yt-dlp: recent yt-dlp rewrites
// the cookie file on close (`--cookies` implies write-back). The system
// file is owned by another user (root/deploy) and the service user
// cannot write it → PermissionError → yt-dlp exits 1 → screen share
// cannot write it → PermissionError → yt-dlp exits 1 → playback
// fails for every attempt. Copy to a per-run temp file (like the env
// branch above) so write-back lands somewhere we own; if the original
// is not readable we fall back to anonymous (YouTube may 403 → the
@@ -305,44 +296,6 @@ function buildCookieArgs(): string[] {
return [];
}
/** Invidious instances for anon YouTube fetch (fallback when cookies 403). */
export const INVIDIOUS_INSTANCES = [
"yewtu.be",
"yewtu.nanomorph.dev",
"invidious.snopyta.org",
"invidious.kavin.rocks",
];
/** True if url is a YouTube watch URL (youtu.be / youtube.com/watch). */
export function isYoutubeWatchUrl(url: string): boolean {
try {
const u = new URL(url);
return (
u.hostname === "youtu.be" ||
(u.hostname === "www.youtube.com" && u.pathname === "/watch") ||
(u.hostname === "youtube.com" && u.pathname === "/watch")
);
} catch {
return false;
}
}
/** Rewrite a YouTube watch URL to an invidious instance (anon, no bot-check). */
export function toInvidiousUrl(url: string, instance: string): string {
try {
const u = new URL(url);
if (u.hostname === "youtu.be") {
const id = u.pathname.slice(1);
return `https://${instance}/watch?v=${id}`;
}
const id = u.searchParams.get("v");
if (id) return `https://${instance}/watch?v=${id}`;
return url;
} catch {
return url;
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -505,114 +458,6 @@ export function resolveMediaUrl(
});
}
/**
* Download the merged video+audio media for screen share to a TEMP FILE
* first, then return the file path.
*
* Screen-share playback does NOT stream from yt-dlp stdout anymore: the
* merge feed is delivered at network speed (bursts + stalls), and a live
* pipe defeats ffmpeg's `-re` throttle (the input PTS timeline is
* unreliable), so the encoder force-duplicates held frames → ~1fps video.
* Downloading the FULL clip to a file first gives the encoder a clean,
* monotonic-PTS input, where `-re` (applied in prepareStream for string
* inputs) pacing is proven reliable.
*
* @returns absolute path of the completed media file (caller should delete
* it via cleanup after playback ends).
*/
export function downloadScreenInput(url: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
// Temp dir per run (world-writable like /tmp) so parallel/retry runs
// never collide on merge fragments.
const tmpDir = mkdtempSync(join(tmpdir(), "gmw-ytdlp-"));
chmodSync(tmpDir, 0o1777);
const cookieArgs = buildCookieArgs();
const args = [
"-f",
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
// File output (NOT `-o -`): yt-dlp merges DASH fragments into a real
// container with clean timestamps, which is what -re needs to pace.
"-o",
join(tmpDir, "media.%(ext)s"),
"--no-playlist",
"--no-warnings",
"--no-progress",
...cookieArgs,
url,
];
logger.info({ url }, "Downloading full media for screen share input");
const proc = spawn("yt-dlp", args, {
stdio: ["ignore", "pipe", "pipe"],
});
activeProcesses.add(proc);
let stderrBuf = "";
const MAX_STDERR = 4096;
proc.stderr?.on("data", (chunk: Buffer) => {
if (stderrBuf.length < MAX_STDERR) {
stderrBuf += chunk.toString("utf8");
}
});
let settled = false;
const failOnce = (message: string) => {
if (settled) return;
settled = true;
activeProcesses.delete(proc);
rmSync(tmpDir, { recursive: true, force: true });
reject(new Error(message));
};
proc.on("error", (err: NodeJS.ErrnoException) => {
failOnce(`yt-dlp failed to start: ${err.message}`);
});
let procFinished = false;
proc.on("close", (code) => {
procFinished = true;
activeProcesses.delete(proc);
if (code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
failOnce(`yt-dlp download failed (exit ${code})${detail}`);
return;
}
// Find the media file yt-dlp wrote (skip .part / .ytdl temp state).
let mediaPath: string | null = null;
for (const entry of readdirSync(tmpDir)) {
if (entry.endsWith(".part") || entry.endsWith(".ytdl")) continue;
mediaPath = join(tmpDir, entry);
break;
}
if (mediaPath && existsSync(mediaPath) && statSync(mediaPath).size > 0) {
settled = true;
resolve(mediaPath);
} else {
failOnce("yt-dlp finished but produced no media file");
}
});
// Safety net: a stalled download must not hang the gateway forever.
const timer = setTimeout(
() => {
try {
proc.kill("SIGTERM");
} catch {
/* already dead */
}
if (!procFinished) {
failOnce("yt-dlp download timed out (10 min)");
}
},
10 * 60 * 1000,
);
proc.once("close", () => clearTimeout(timer));
});
}
/**
* Extract metadata (title, duration, thumbnail) from a media URL
* without downloading the audio stream.
@@ -1,7 +1,7 @@
import type { Readable } from "node:stream";
import type { StreamType } from "@discordjs/voice";
export type MediaMode = "music" | "screen";
export type MediaMode = "music";
export type MediaSourceKind =
| "url"
| "local"
@@ -51,23 +51,7 @@ export interface MusicPlayer {
play(source: ResolvedMediaSource): MusicPlayback;
}
export interface ScreenSharePlayback {
done: Promise<void>;
stop(): void;
}
export interface ScreenShareVoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
}
export interface ScreenShareController {
isActive(): boolean;
start(source: string): Promise<ScreenSharePlayback>;
}
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music";
export interface DiscordPlayOptions {
inputType?: StreamType;
@@ -1,291 +0,0 @@
import { rmSync } from "node:fs";
import { dirname } from "node:path";
import type { Client } from "discord.js-selfbot-v13";
import { createChildLogger } from "@/shared/logger/index";
import {
Encoders,
normalizeVideoCodec,
playStream,
prepareStream,
Streamer,
} from "../../goLive/index.js";
import {
downloadScreenInput,
INVIDIOUS_INSTANCES,
isYoutubeWatchUrl,
toInvidiousUrl,
} from "./mediaSource.js";
import type { ScreenSharePlayback } from "./mediaTypes.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,
/** Disconnect the @discordjs/voice connection so the Streamer can take
* over the voice channel (Discord allows only ONE voice session per user
* two connections collide and the Streamer never gets VOICE_SERVER_UPDATE). */
private readonly releaseVoice: (
status: ScreenShareVoiceStatus,
) => void | Promise<void>,
/** Reconnect the @discordjs/voice connection after the stream ends. */
private readonly restoreVoice: (
status: ScreenShareVoiceStatus,
) => void | Promise<void>,
) {}
isActive(): boolean {
return this.active !== null;
}
/**
* Resolve the screen-share input with retry (downloads the FULL media to a
* temp file; returns the file path).
*
* Transient YouTube 403s kill a download BEFORE completion; we validate
* the finished file and retry with a FRESH yt-dlp run (signed DASH URLs
* expire quickly). Downloading to a file (instead of streaming the merge
* pipe) gives the encoder a monotonic-PTS input, so ffmpeg `-re` pacing
* in prepareStream actually works (it does NOT on live pipes the root
* of the ~1fps video).
*/
private async resolveInputWithRetry(source: string): Promise<string> {
const MAX_ATTEMPTS = 3;
let lastError: Error | null = null;
// YouTube may 403 even with account cookies (IP-bound session / bot check
// on VPS IP). When the source is a YouTube URL and cookies fail, fall back
// to anon Invidious mirror instances — no auth needed.
const isYt = isYoutubeWatchUrl(source);
let invidiousIdx = 0;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
// On a 403 against YouTube, try the next Invidious instance for this attempt.
if (
isYt &&
lastError &&
/403|bot|Sign in|not a bot|access denied|permission|EACCES|cookie/i.test(
lastError.message,
) &&
invidiousIdx < INVIDIOUS_INSTANCES.length
) {
const inst = INVIDIOUS_INSTANCES[invidiousIdx];
this.logger.warn(
{ attempt, instance: inst, error: lastError.message },
"YouTube blocked (403); falling back to Invidious mirror",
);
source = toInvidiousUrl(source, inst);
invidiousIdx++;
}
try {
const mediaPath = await downloadScreenInput(source);
this.logger.info(
{ mediaPath, attempt },
"Screen input downloaded to file",
);
return mediaPath;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
this.logger.warn(
{
attempt,
maxAttempts: MAX_ATTEMPTS,
error: lastError.message,
},
"Screen input download failed; retrying with fresh yt-dlp",
);
if (attempt < MAX_ATTEMPTS) {
await new Promise((r) => setTimeout(r, 1500 * attempt));
}
}
}
throw (
lastError ??
new Error("Screen input resolution failed after multiple attempts")
);
}
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 input = await this.resolveInputWithRetry(source);
if (!this.streamer) {
this.streamer = new Streamer(this.client);
}
const guild = this.client.guilds.cache.get(status.activeGuildId);
const channel = guild?.channels.cache.get(status.activeChannelId);
if (
!channel ||
(channel.type !== "GUILD_VOICE" && channel.type !== "GUILD_STAGE_VOICE")
) {
throw new Error(
`Voice channel ${status.activeChannelId} not found for screen share`,
);
}
// Free the @discordjs/voice connection BEFORE the Streamer joins, so
// the user has only one voice session (Discord requirement).
await this.releaseVoice(status);
await Promise.race([
this.streamer.joinVoiceChannel(channel),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error("Timed out joining voice channel for screen share"),
),
15000,
),
),
]);
const prepared = prepareStream(input, {
encoder: Encoders.software({ x264: { preset: "superfast" } }),
width: 1280,
height: 720,
frameRate: 30,
bitrateVideo: 2500,
bitrateVideoMax: 4000,
// GoLive with audio: the encoder muxes to NUT (video h264 + opus
// audio) so the audio SSRC carries RTP too. Discord's GoLive
// pipeline expects audio — a video-only stream shows a static
// tile/thumbnail instead of live video. When the source has no
// audio track, the encoder's `-map 0:a:0?` yields no audio stream
// and the demuxer simply reports none (video still flows).
includeAudio: true,
videoCodec: normalizeVideoCodec("H264"),
});
const { command } = prepared;
// The downloaded temp media lives in a per-run tmpdir; remove it once
// playback is done (natural end, failure, or user stop). The tmpdir is
// the parent of the media file, so deleting it removes the file too.
const cleanupTempMedia = () => {
try {
if (typeof input === "string") {
rmSync(dirname(input), { recursive: true, force: true });
}
} catch {
/* best-effort — tmp dirs are world-writable, leak is bounded */
}
};
let stopped = false;
// Restore the @discordjs/voice connection after the stream ends (both
// natural end and failure), so the user can keep using audio/mic.
const restoreAfter = () => {
if (!stopped) {
stopped = true;
try {
command.kill("SIGTERM");
} catch {
/* already dead */
}
}
cleanupTempMedia();
try {
this.streamer?.voiceConnection?.stop();
} catch {
/* already gone */
}
if (this.restoreVoice) {
// Best-effort restore after a short delay. Discord often needs the
// Streamer's session fully torn down before @discordjs/voice can
// re-join; if that races, the reconnect times out — the FE shows
// disconnected and the user just clicks Connect again. This is an
// accepted UX tradeoff for GoLive (single voice session per user).
setTimeout(() => {
Promise.resolve(this.restoreVoice(status)).catch((err) => {
this.logger.warn(
{ error: err instanceof Error ? err.message : String(err) },
"Failed to restore voice connection after screen share (user can reconnect manually)",
);
});
}, 5000);
}
};
const done = playStream(prepared, this.streamer, {
type: "go-live",
width: 1280,
height: 720,
frameRate: 30,
})
.catch((err: unknown) => {
// Never let a stream failure become an unhandledRejection — that
// crashed the whole gateway. Log + surface via the done promise.
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
{ error: message, source },
"Screen stream failed during playback",
);
})
.finally(() => {
restoreAfter();
this.active = null;
});
this.active = {
done,
stop: () => {
if (stopped) return;
stopped = true;
try {
command.kill("SIGTERM");
} catch {
/* already dead */
}
cleanupTempMedia();
// Leave the voice channel the Streamer joined (its own connection).
try {
this.streamer?.voiceConnection?.stop();
} catch {
/* already gone */
}
this.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;
}
}
}