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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user