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
+1 -1
View File
@@ -28,12 +28,12 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@dank074/discord-video-stream": "6.0.0",
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2", "@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11", "@snazzah/davey": "^0.1.11",
"axios": "^1.16.1", "axios": "^1.16.1",
"discord.js-selfbot-v13": "^3.7.1", "discord.js-selfbot-v13": "^3.7.1",
"@dank074/discord-video-stream": "latest",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"imghash": "^1.1.4", "imghash": "^1.1.4",
File diff suppressed because it is too large Load Diff
@@ -82,7 +82,7 @@ export class CommandHandler {
// Create domain-specific handlers with their dependencies // Create domain-specific handlers with their dependencies
this.voiceHandler = new VoiceHandler(client, voiceController); this.voiceHandler = new VoiceHandler(client, voiceController);
this.mediaHandler = new MediaHandler(); this.mediaHandler = new MediaHandler(client, () => voiceController.getStatus());
this.guildHandler = new GuildHandler(client); this.guildHandler = new GuildHandler(client);
this.moderationHandler = new ModerationHandler(client); this.moderationHandler = new ModerationHandler(client);
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { Client } from "discord.js-selfbot-v13";
import type { CommandMessage, CommandReply } from "../../shared/index.js"; import type { CommandMessage, CommandReply } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js"; import { createChildLogger } from "../../shared/logger/index.js";
import { StreamType } from "@discordjs/voice"; import { StreamType } from "@discordjs/voice";
@@ -11,6 +12,10 @@ import type {
MediaQueueItem, MediaQueueItem,
} from "../voice-recording/mediaTypes.js"; } from "../voice-recording/mediaTypes.js";
import { discordPlayer } from "../voice-recording/player.js"; import { discordPlayer } from "../voice-recording/player.js";
import {
ScreenShareController,
type ScreenShareVoiceStatus,
} from "../voice-recording/screenShareController.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -27,6 +32,7 @@ interface MediaStatusItem {
export interface MediaStatusPayload { export interface MediaStatusPayload {
playing: boolean; playing: boolean;
activeMode: MediaMode | null;
musicVolume: number; musicVolume: number;
current: MediaStatusItem | null; current: MediaStatusItem | null;
queue: MediaStatusItem[]; queue: MediaStatusItem[];
@@ -58,6 +64,7 @@ function buildStatusPayload(): MediaStatusPayload {
return { return {
playing: playing:
currentTrackItem !== null && discordPlayer.getStatus() === "playing", currentTrackItem !== null && discordPlayer.getStatus() === "playing",
activeMode: currentTrackItem?.mode ?? null,
musicVolume: discordPlayer.getMusicVolume(), musicVolume: discordPlayer.getMusicVolume(),
current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null, current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null,
queue: mediaQueue.map(mapToStatusItem), queue: mediaQueue.map(mapToStatusItem),
@@ -70,8 +77,17 @@ function buildStatusPayload(): MediaStatusPayload {
export class MediaHandler { export class MediaHandler {
private logger = createChildLogger("media-handler"); private logger = createChildLogger("media-handler");
private screenController: ScreenShareController | null = null;
private screenPlayback: { stop(): void } | null = null;
constructor() { constructor(
private readonly client: Client | null = null,
private readonly getVoiceStatus: () => ScreenShareVoiceStatus = () => ({
connected: false,
activeGuildId: null,
activeChannelId: null,
}),
) {
// Register auto-advance on natural track end // Register auto-advance on natural track end
discordPlayer.onIdle(() => { discordPlayer.onIdle(() => {
this.advanceQueue().catch((err) => { this.advanceQueue().catch((err) => {
@@ -109,6 +125,50 @@ 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,
);
}
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.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 // Lightweight metadata fetch for display — the full resolve happens in playNext
let title: string = url; let title: string = url;
let duration: number | undefined; let duration: number | undefined;
@@ -174,6 +234,12 @@ export class MediaHandler {
} }
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> { 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"); discordPlayer.stop("music");
currentTrackItem = null; currentTrackItem = null;
mediaQueue.length = 0; // Clear entire queue mediaQueue.length = 0; // Clear entire queue
@@ -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 * Extract metadata (title, duration, thumbnail) from a media URL
* without downloading the audio stream. * 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;
}
}
}
@@ -29,15 +29,19 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
const stopMut = useMediaStop(); const stopMut = useMediaStop();
const volumeMut = useMediaVolume(); const volumeMut = useMediaVolume();
const [queueUrl, setQueueUrl] = useState(""); const [queueUrl, setQueueUrl] = useState("");
const [screenMode, setScreenMode] = useState(false);
// Sync WS media_state into the query cache // Sync WS media_state into the query cache
useMediaWsSync(ws); useMediaWsSync(ws);
const handleQueue = useCallback(() => { const handleQueue = useCallback(() => {
if (!queueUrl.trim()) return; if (!queueUrl.trim()) return;
queueMut.mutate(queueUrl.trim()); queueMut.mutate({
url: queueUrl.trim(),
mode: screenMode ? "screen" : "music",
});
setQueueUrl(""); setQueueUrl("");
}, [queueUrl, queueMut]); }, [queueUrl, queueMut, screenMode]);
const handleVolume = useCallback( const handleVolume = useCallback(
(value: number | readonly number[]) => { (value: number | readonly number[]) => {
@@ -64,6 +68,15 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
onKeyDown={(e) => e.key === "Enter" && handleQueue()} onKeyDown={(e) => e.key === "Enter" && handleQueue()}
className="flex-1 h-9" className="flex-1 h-9"
/> />
<Button
variant={screenMode ? "default" : "secondary"}
size="sm"
onClick={() => setScreenMode((v) => !v)}
title="Queue as Discord GoLive screenshare instead of audio playback"
className="h-9"
>
Screen
</Button>
<Button <Button
onClick={handleQueue} onClick={handleQueue}
disabled={!queueUrl.trim() || queueMut.isPending} disabled={!queueUrl.trim() || queueMut.isPending}
@@ -73,6 +86,14 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
</Button> </Button>
</div> </div>
{mediaState?.activeMode && (
<p className="text-[10px] font-mono text-primary/80 uppercase tracking-wider">
{mediaState.activeMode === "screen"
? "Screen share active"
: "Music playing"}
</p>
)}
{mediaState?.current ? ( {mediaState?.current ? (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4 space-y-2"> <div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4 space-y-2">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider flex items-center gap-1.5"> <p className="text-xs text-muted-foreground font-medium uppercase tracking-wider flex items-center gap-1.5">
+4 -1
View File
@@ -24,7 +24,10 @@ function useMediaAction<TArgs>(fn: (args: TArgs) => Promise<MediaState>) {
} }
export function useMediaQueue() { export function useMediaQueue() {
return useMediaAction((url: string) => mediaApi.queue(url, "music")); return useMediaAction(
(input: { url: string; mode?: "music" | "screen" }) =>
mediaApi.queue(input.url, input.mode ?? "music"),
);
} }
export function useMediaSkip() { export function useMediaSkip() {
+1
View File
@@ -11,6 +11,7 @@ export interface MediaItem {
export interface MediaState { export interface MediaState {
playing: boolean; playing: boolean;
activeMode?: MediaMode | null;
musicVolume: number; musicVolume: number;
current: MediaItem | null; current: MediaItem | null;
queue: MediaItem[]; queue: MediaItem[];