chore(auto): task completed - unknown
This commit is contained in:
@@ -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<CommandReply<unknown>> {
|
||||
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<CommandReply<unknown>> {
|
||||
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<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>> {
|
||||
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<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;
|
||||
/** Duration in seconds, if known. */
|
||||
duration?: number;
|
||||
/** Thumbnail/preview URL, if available. */
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
|
||||
Reference in New Issue
Block a user