chore(auto): task completed - unknown

This commit is contained in:
MythEclipse
2026-06-13 15:11:06 +07:00
parent 265251233a
commit d0d2c316cf
2 changed files with 189 additions and 56 deletions
@@ -1,24 +1,62 @@
import { type CommandMessage, type CommandReply } from "@bete/shared"; import { type CommandMessage, type CommandReply } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { StreamType } from "@discordjs/voice"; 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 { discordPlayer } from "../voice-recording/player.js";
import type { MediaMode, MediaQueueItem } from "../voice-recording/mediaTypes.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface CurrentTrack { interface MediaStatusItem {
id: string;
source: string;
title: string; title: string;
url: string; mode: MediaMode;
duration?: number; durationMs?: number | null;
thumbnailUrl?: string | null;
} }
export interface MediaStatusPayload { export interface MediaStatusPayload {
playing: boolean; playing: boolean;
musicVolume: number; musicVolume: number;
current: CurrentTrack | null; current: MediaStatusItem | null;
queue: unknown[]; 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 { export class MediaHandler {
private logger = createChildLogger("media-handler"); 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 { getCurrentMediaStatus(): MediaStatusPayload {
return { return buildStatusPayload();
playing: discordPlayer.getStatus() === "playing",
musicVolume: discordPlayer.getMusicVolume(),
current: this.currentTrack,
queue: [],
};
} }
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> { async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
const url = String(cmd.payload.url ?? "").trim(); 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) { if (!url) {
this.logger.warn("media:queue received without a URL"); this.logger.warn("media:queue received without a URL");
@@ -53,7 +97,7 @@ export class MediaHandler {
if (!discordPlayer.isConnected()) { if (!discordPlayer.isConnected()) {
this.logger.warn( this.logger.warn(
"media:queue attempted without an active voice connection", "media:queue attempted without active voice connection",
); );
return { return {
id: cmd.id, 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 { try {
this.logger.info({ url }, "Resolving media URL"); const info = await extractMediaInfo(url);
title = info.title ?? url;
const resolution = await resolveMediaUrl(url); duration = info.duration;
thumbnailUrl = info.thumbnail;
this.currentTrack = { } catch {
title: resolution.title ?? url, this.logger.warn(
url, { url },
duration: resolution.duration, "Could not pre-resolve media metadata, queueing blind",
};
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",
); );
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<CommandReply<unknown>> { const item: MediaQueueItem = {
discordPlayer.stop("music"); id: randomUUID(),
this.currentTrack = null; 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 { return {
id: cmd.id, id: cmd.id,
success: true, success: true,
data: this.getCurrentMediaStatus(), data: buildStatusPayload(),
};
}
async handleMediaSkip(cmd: CommandMessage): Promise<CommandReply<unknown>> {
if (currentTrackItem) {
discordPlayer.stop("music");
currentTrackItem = null;
}
await this.playNext();
return {
id: cmd.id,
success: true,
data: buildStatusPayload(),
}; };
} }
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> { async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
discordPlayer.stop("music"); discordPlayer.stop("music");
this.currentTrack = null; currentTrackItem = null;
mediaQueue.length = 0; // Clear entire queue
return { return {
id: cmd.id, id: cmd.id,
success: true, success: true,
data: this.getCurrentMediaStatus(), data: buildStatusPayload(),
}; };
} }
@@ -136,7 +196,78 @@ export class MediaHandler {
return { return {
id: cmd.id, id: cmd.id,
success: true, 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<void> {
// 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<void> {
currentTrackItem = null;
await this.playNext();
}
} }
@@ -24,6 +24,8 @@ export interface MediaQueueItem extends ResolvedMediaSource {
status: MediaQueueItemStatus; status: MediaQueueItemStatus;
/** Duration in seconds, if known. */ /** Duration in seconds, if known. */
duration?: number; duration?: number;
/** Thumbnail/preview URL, if available. */
thumbnailUrl?: string;
} }
export interface MediaState { export interface MediaState {