Backend (real implementations, no more stubs):
- Redis pub/sub bridge: subscribes to discord-gateway events (message/attachment/voice) and broadcasts to WS clients
- Redis command channel: backend publishes voice/media commands, discord-gateway executes and replies
- Voice service: connectVoice/disconnectVoice/getVoiceStatus via Redis commands with graceful fallback
- Media service: queue/skip/stop/volume via Redis commands, reads status from Redis cache
- Messages repository: ALL 7 methods now use real PostgreSQL queries (findMany, findById, findByChannel, create, update, delete, getAttachmentsByChannel)
- Analytics: period returns {start,end} epoch millis, overview includes hourly/topics/top_users, worst_flags as string[]
- Health check: actually queries SELECT 1 against database
- VoiceStatus type fixed: {connected, activeGuildId, activeChannelId, activeChannelName}
- Guild type: includes icon: string | null
- asyncHandler: accepts Promise<unknown> instead of Promise<void>
Discord Gateway:
- CommandHandler: subscribes to 'backend:command' Redis channel, executes voice/media commands, publishes replies
- Publishes voice:status and media:status to Redis for backend caching
- Shutdown handler updated to close command handler
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
3.9 KiB
TypeScript
161 lines
3.9 KiB
TypeScript
import { createChildLogger } from "../../shared/logger/index.js";
|
|
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
|
|
|
const logger = createChildLogger("media.service");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types — match frontend exactly
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface MediaItem {
|
|
id?: string;
|
|
source: string;
|
|
title: string;
|
|
mode?: "music" | "screen";
|
|
durationMs?: number | null;
|
|
thumbnailUrl?: string | null;
|
|
}
|
|
|
|
export interface MediaState {
|
|
playing: boolean;
|
|
musicVolume: number;
|
|
current: MediaItem | null;
|
|
queue: MediaItem[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Defaults
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
|
|
|
|
const DEFAULT_STATE: MediaState = {
|
|
playing: false,
|
|
musicVolume: 1.0,
|
|
current: null,
|
|
queue: [],
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Service methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Read media status from Redis key "media:status" set by discord-gateway.
|
|
*/
|
|
export async function getStatus(): Promise<MediaState> {
|
|
const cached = await readRedisStatus("media:status");
|
|
|
|
if (cached) {
|
|
return {
|
|
playing: Boolean(cached.playing),
|
|
musicVolume: Number(cached.musicVolume ?? 1.0),
|
|
current: (cached.current as MediaItem | null) ?? null,
|
|
queue: (cached.queue as MediaItem[]) ?? [],
|
|
};
|
|
}
|
|
|
|
return DEFAULT_STATE;
|
|
}
|
|
|
|
/**
|
|
* Queue a media source via Redis command to discord-gateway.
|
|
*/
|
|
export async function queue(
|
|
source: string,
|
|
mode: "music" | "screen" = "music",
|
|
): Promise<MediaState> {
|
|
const reply = await publishCommand<MediaState>(
|
|
"media:queue",
|
|
{ source, mode },
|
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
);
|
|
|
|
if (reply?.success && reply.data) {
|
|
return {
|
|
playing: reply.data.playing,
|
|
musicVolume: reply.data.musicVolume,
|
|
current: reply.data.current ?? null,
|
|
queue: reply.data.queue ?? [],
|
|
};
|
|
}
|
|
|
|
logger.warn(
|
|
{ source, mode },
|
|
"discord-gateway unreachable, returning current media status",
|
|
);
|
|
return getStatus();
|
|
}
|
|
|
|
/**
|
|
* Skip current track via Redis command to discord-gateway.
|
|
*/
|
|
export async function skip(): Promise<MediaState> {
|
|
const reply = await publishCommand<MediaState>(
|
|
"media:skip",
|
|
{},
|
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
);
|
|
|
|
if (reply?.success && reply.data) {
|
|
return {
|
|
playing: reply.data.playing,
|
|
musicVolume: reply.data.musicVolume,
|
|
current: reply.data.current ?? null,
|
|
queue: reply.data.queue ?? [],
|
|
};
|
|
}
|
|
|
|
logger.warn("discord-gateway unreachable, returning current media status");
|
|
return getStatus();
|
|
}
|
|
|
|
/**
|
|
* Stop playback via Redis command to discord-gateway.
|
|
*/
|
|
export async function stop(): Promise<MediaState> {
|
|
const reply = await publishCommand<MediaState>(
|
|
"media:stop",
|
|
{},
|
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
);
|
|
|
|
if (reply?.success && reply.data) {
|
|
return {
|
|
playing: reply.data.playing,
|
|
musicVolume: reply.data.musicVolume,
|
|
current: reply.data.current ?? null,
|
|
queue: reply.data.queue ?? [],
|
|
};
|
|
}
|
|
|
|
logger.warn("discord-gateway unreachable, returning current media status");
|
|
return getStatus();
|
|
}
|
|
|
|
/**
|
|
* Set volume via Redis command to discord-gateway.
|
|
*/
|
|
export async function setVolume(volume: number): Promise<MediaState> {
|
|
const reply = await publishCommand<MediaState>(
|
|
"media:volume",
|
|
{ volume },
|
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
);
|
|
|
|
if (reply?.success && reply.data) {
|
|
return {
|
|
playing: reply.data.playing,
|
|
musicVolume: reply.data.musicVolume,
|
|
current: reply.data.current ?? null,
|
|
queue: reply.data.queue ?? [],
|
|
};
|
|
}
|
|
|
|
logger.warn(
|
|
{ volume },
|
|
"discord-gateway unreachable, returning current media status",
|
|
);
|
|
return getStatus();
|
|
}
|