From dbcf9d68f2514d68714ef46c34ef1b966e8f844e Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 2 Aug 2026 10:43:34 +0700 Subject: [PATCH] fix(media): publish status when a track ends naturally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Redis media:status key was only rewritten after a command received via Redis. When the last track ended naturally (AudioPlayer Idle -> advanceQueue with an empty queue), currentTrackItem was cleared but the status key was not persisted — so the backend's cached status and the frontend's 10s polling stayed stuck showing the finished track as 'playing' forever. Wire a media-status sink (commandHandler provides the real redisPub to MediaHandler) and re-publish status after auto-advance, so natural track end updates the UI. --- .../modules/command-handler/commandHandler.ts | 5 +++ .../modules/command-handler/media.handler.ts | 31 ++++++++++++-- .../command-handler/mediaStatusSink.ts | 41 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 services/discord-gateway/src/modules/command-handler/mediaStatusSink.ts diff --git a/services/discord-gateway/src/modules/command-handler/commandHandler.ts b/services/discord-gateway/src/modules/command-handler/commandHandler.ts index cd94f12..deff43d 100644 --- a/services/discord-gateway/src/modules/command-handler/commandHandler.ts +++ b/services/discord-gateway/src/modules/command-handler/commandHandler.ts @@ -16,6 +16,7 @@ import { createHandlerRegistry, } from "./handler-registry.js"; import { MediaHandler } from "./media.handler.js"; +import { wireMediaStatusWriter } from "./mediaStatusSink.js"; import { ModerationHandler } from "./moderation.handler.js"; import { VoiceHandler } from "./voice.handler.js"; @@ -88,6 +89,10 @@ export class CommandHandler { this.guildHandler = new GuildHandler(client); this.moderationHandler = new ModerationHandler(client); + // Wire the media status sink so MediaHandler can persist status on + // queue advances that happen outside a command (natural track end). + wireMediaStatusWriter(this.redisPub); + // Build the command registry this.registry = createHandlerRegistry( this.voiceHandler, 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 b62efea..2dd3181 100644 --- a/services/discord-gateway/src/modules/command-handler/media.handler.ts +++ b/services/discord-gateway/src/modules/command-handler/media.handler.ts @@ -16,6 +16,7 @@ import { ScreenShareController, type ScreenShareVoiceStatus, } from "../voice-recording/screenShareController.js"; +import { setMediaStatusKey } from "./mediaStatusSink.js"; // --------------------------------------------------------------------------- // Types @@ -88,14 +89,36 @@ export class MediaHandler { activeChannelId: null, }), ) { - // Register auto-advance on natural track end + // Register auto-advance on natural track end. advanceQueue mutates the + // module-level currentTrackItem/queue, so we must re-publish the status + // key afterward: otherwise the backend's Redis `media:status` cache (and + // the frontend's 10s polling) stays stuck on the finished track. discordPlayer.onIdle(() => { - this.advanceQueue().catch((err) => { - this.logger.error({ err }, "Auto-advance failed"); - }); + this.advanceQueue() + .then(() => this.publishStatus()) + .catch((err) => { + this.logger.error({ err }, "Auto-advance failed"); + }); }); } + /** + * Persist the latest media state to Redis so the backend/frontend see queue + * advances that happen outside a command (natural track end, screen-share + * done). CommandHandler owns the Redis status-key writes for command-triggered + * changes; this covers the side-effect-only path. + */ + private publishStatus(): void { + try { + setMediaStatusKey(this.getCurrentMediaStatus()); + } catch (err: unknown) { + this.logger.warn( + { error: err instanceof Error ? err.message : String(err) }, + "Failed to publish media status on track end", + ); + } + } + getCurrentMediaStatus(): MediaStatusPayload { return buildStatusPayload(); } diff --git a/services/discord-gateway/src/modules/command-handler/mediaStatusSink.ts b/services/discord-gateway/src/modules/command-handler/mediaStatusSink.ts new file mode 100644 index 0000000..3534b34 --- /dev/null +++ b/services/discord-gateway/src/modules/command-handler/mediaStatusSink.ts @@ -0,0 +1,41 @@ +import type Redis from "ioredis"; +import { createChildLogger } from "@/shared/logger/index"; +import { MEDIA_STATUS_KEY } from "../../shared/redis-channels.js"; + +/** + * Shared sink for writing the media status Redis key. + * + * CommandHandler owns the publisher + status writes for command-triggered + * changes (`publishMediaStatus`). MediaHandler needs to also persist status + * when the queue advances *outside* a command (natural track end / screen-share + * done), so we expose the real publisher here and let CommandHandler wire it + * once at startup. + */ +const logger = createChildLogger("media-status-sink"); + +let _setMediaStatusKey: ((payload: unknown) => void) | null = null; + +export function setMediaStatusWriter(writer: (payload: unknown) => void): void { + _setMediaStatusKey = writer; +} + +export function setMediaStatusKey(payload: unknown): void { + if (!_setMediaStatusKey) { + logger.warn("Media status writer not wired — skipping status publish"); + return; + } + _setMediaStatusKey(payload); +} + +export { MEDIA_STATUS_KEY }; + +export function wireMediaStatusWriter(redisPub: Redis): void { + setMediaStatusWriter((payload) => { + redisPub + .set(MEDIA_STATUS_KEY, JSON.stringify(payload)) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn({ error: msg }, "Failed to update media status key"); + }); + }); +}