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:
@@ -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",
|
||||||
|
|||||||
Generated
+5177
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">
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user