From d0d2c316cf784aa099eaf12329e8f6917a817d7c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 13 Jun 2026 15:11:06 +0700 Subject: [PATCH] chore(auto): task completed - unknown --- .../modules/command-handler/media.handler.ts | 243 ++++++++++++++---- .../src/modules/voice-recording/mediaTypes.ts | 2 + 2 files changed, 189 insertions(+), 56 deletions(-) diff --git a/services/discord-gateway/src/modules/command-handler/media.handler.ts b/services/discord-gateway/src/modules/command-handler/media.handler.ts index 1beedcd..c0d62a6 100644 --- a/services/discord-gateway/src/modules/command-handler/media.handler.ts +++ b/services/discord-gateway/src/modules/command-handler/media.handler.ts @@ -1,24 +1,62 @@ import { type CommandMessage, type CommandReply } from "@bete/shared"; import { createChildLogger } from "@bete/shared/logger"; import { StreamType } from "@discordjs/voice"; -import { resolveMediaUrl } from "../voice-recording/mediaSource.js"; +import { randomUUID } from "node:crypto"; +import { extractMediaInfo, resolveMediaUrl } from "../voice-recording/mediaSource.js"; import { discordPlayer } from "../voice-recording/player.js"; +import type { MediaMode, MediaQueueItem } from "../voice-recording/mediaTypes.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -interface CurrentTrack { +interface MediaStatusItem { + id: string; + source: string; title: string; - url: string; - duration?: number; + mode: MediaMode; + durationMs?: number | null; + thumbnailUrl?: string | null; } export interface MediaStatusPayload { playing: boolean; musicVolume: number; - current: CurrentTrack | null; - queue: unknown[]; + current: MediaStatusItem | null; + queue: MediaStatusItem[]; +} + +// --------------------------------------------------------------------------- +// Module-level queue +// --------------------------------------------------------------------------- + +const mediaQueue: MediaQueueItem[] = []; +let currentTrackItem: MediaQueueItem | null = null; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mapToStatusItem(item: MediaQueueItem): MediaStatusItem { + return { + id: item.id, + source: item.source, + title: item.title, + mode: item.mode, + durationMs: item.duration != null ? item.duration * 1000 : null, + thumbnailUrl: item.thumbnailUrl ?? null, + }; +} + +function buildStatusPayload(): MediaStatusPayload { + return { + playing: + currentTrackItem !== null && + discordPlayer.getStatus() === "playing", + musicVolume: discordPlayer.getMusicVolume(), + current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null, + queue: mediaQueue.map(mapToStatusItem), + }; } // --------------------------------------------------------------------------- @@ -27,19 +65,25 @@ export interface MediaStatusPayload { export class MediaHandler { private logger = createChildLogger("media-handler"); - private currentTrack: CurrentTrack | null = null; + + constructor() { + // Register auto-advance on natural track end + discordPlayer.onIdle(() => { + this.advanceQueue().catch((err) => { + this.logger.error({ err }, "Auto-advance failed"); + }); + }); + } getCurrentMediaStatus(): MediaStatusPayload { - return { - playing: discordPlayer.getStatus() === "playing", - musicVolume: discordPlayer.getMusicVolume(), - current: this.currentTrack, - queue: [], - }; + return buildStatusPayload(); } async handleMediaQueue(cmd: CommandMessage): Promise> { const url = String(cmd.payload.url ?? "").trim(); + const mode: MediaMode = + cmd.payload.mode === "screen" ? "screen" : "music"; + const requestedBy = String(cmd.payload.requestedBy ?? "unknown"); if (!url) { this.logger.warn("media:queue received without a URL"); @@ -53,7 +97,7 @@ export class MediaHandler { if (!discordPlayer.isConnected()) { this.logger.warn( - "media:queue attempted without an active voice connection", + "media:queue attempted without active voice connection", ); return { id: cmd.id, @@ -63,62 +107,78 @@ export class MediaHandler { }; } + // Lightweight metadata fetch for display — the full resolve happens in playNext + let title: string = url; + let duration: number | undefined; + let thumbnailUrl: string | undefined; try { - this.logger.info({ url }, "Resolving media URL"); - - const resolution = await resolveMediaUrl(url); - - this.currentTrack = { - title: resolution.title ?? url, - url, - duration: resolution.duration, - }; - - discordPlayer.playStream(resolution.stream, "music", { - inputType: StreamType.Arbitrary, - inlineVolume: true, - volume: discordPlayer.getMusicVolume(), - }); - - this.logger.info( - { url, title: resolution.title }, - "Media queued and playback started", + const info = await extractMediaInfo(url); + title = info.title ?? url; + duration = info.duration; + thumbnailUrl = info.thumbnail; + } catch { + this.logger.warn( + { url }, + "Could not pre-resolve media metadata, queueing blind", ); - - return { - id: cmd.id, - success: true, - data: this.getCurrentMediaStatus(), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error({ error: message, url }, "Failed to queue media"); - return { - id: cmd.id, - success: false, - data: null, - error: message, - }; } - } - async handleMediaSkip(cmd: CommandMessage): Promise> { - discordPlayer.stop("music"); - this.currentTrack = null; + const item: MediaQueueItem = { + id: randomUUID(), + source: url, + title, + kind: "url" as const, + mode, + requestedBy, + addedAt: Date.now(), + status: "queued", + duration, + thumbnailUrl, + }; + + mediaQueue.push(item); + this.logger.info( + { url, title, queueLength: mediaQueue.length }, + "Media queued", + ); + + // If nothing is playing, start immediately (next tick so status publishes) + if (currentTrackItem === null) { + setImmediate(() => { + this.playNext().catch((err) => { + this.logger.error({ err }, "playNext after queue failed"); + }); + }); + } + return { id: cmd.id, success: true, - data: this.getCurrentMediaStatus(), + data: buildStatusPayload(), + }; + } + + async handleMediaSkip(cmd: CommandMessage): Promise> { + if (currentTrackItem) { + discordPlayer.stop("music"); + currentTrackItem = null; + } + await this.playNext(); + return { + id: cmd.id, + success: true, + data: buildStatusPayload(), }; } async handleMediaStop(cmd: CommandMessage): Promise> { discordPlayer.stop("music"); - this.currentTrack = null; + currentTrackItem = null; + mediaQueue.length = 0; // Clear entire queue return { id: cmd.id, success: true, - data: this.getCurrentMediaStatus(), + data: buildStatusPayload(), }; } @@ -136,7 +196,78 @@ export class MediaHandler { return { id: cmd.id, success: true, - data: this.getCurrentMediaStatus(), + data: buildStatusPayload(), }; } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + /** + * Advance to the next item in the queue. Called after natural track end + * (via onIdle) or after manual skip. + */ + private async playNext(): Promise { + // Stop any currently playing track (without triggering idle callback) + if (currentTrackItem) { + discordPlayer.stop("music"); + currentTrackItem = null; + } + + const next = mediaQueue.shift(); + if (!next) { + this.logger.info("Queue empty — nothing to play"); + return; + } + + currentTrackItem = next; + next.status = "playing"; + + try { + this.logger.info( + { url: next.source, title: next.title }, + "Playing next from queue", + ); + + const resolution = await resolveMediaUrl(next.source); + + // Update title/duration with actual resolved values + next.title = resolution.title ?? next.title; + next.duration = resolution.duration ?? next.duration; + + discordPlayer.playStream(resolution.stream, "music", { + inputType: StreamType.Arbitrary, + inlineVolume: true, + volume: discordPlayer.getMusicVolume(), + }); + + this.logger.info({ title: next.title }, "Playback started"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + { error: message, url: next.source, title: next.title }, + "Failed to play queued item, skipping to next", + ); + + currentTrackItem = null; + next.status = "failed"; + + // Try the next item in the queue + setImmediate(() => { + this.playNext().catch((err2) => { + this.logger.error({ err: err2 }, "playNext after error recovery failed"); + }); + }); + } + } + + /** + * Called by the idle callback — delegates to playNext since the player is + * already idle and currentTrackItem is already null. + */ + private async advanceQueue(): Promise { + currentTrackItem = null; + await this.playNext(); + } } diff --git a/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts b/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts index 2c132a7..bc68fbd 100644 --- a/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts +++ b/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts @@ -24,6 +24,8 @@ export interface MediaQueueItem extends ResolvedMediaSource { status: MediaQueueItemStatus; /** Duration in seconds, if known. */ duration?: number; + /** Thumbnail/preview URL, if available. */ + thumbnailUrl?: string; } export interface MediaState {