Compare commits
19 Commits
6859eb3f50
...
61b07e4b01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b07e4b01 | ||
|
|
a97feb1e2a | ||
|
|
575302db57 | ||
|
|
76470a5129 | ||
|
|
ff2792d403 | ||
|
|
192f83d31d | ||
|
|
06b6db703c | ||
|
|
94e497b7a6 | ||
|
|
b00def2d4d | ||
|
|
dbae042279 | ||
|
|
c509f48f95 | ||
|
|
1e0a00d82d | ||
|
|
9e07a0a1f3 | ||
|
|
acb43b6dac | ||
|
|
93134a9793 | ||
|
|
2194d4a8b6 | ||
|
|
3b6bf49160 | ||
|
|
d42d3f8def | ||
|
|
ed438e6fc0 |
11
.env.example
11
.env.example
@@ -25,12 +25,21 @@ WEBSERVER_PORT=3000
|
|||||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
VOICE_CONNECTION_TIMEOUT_MS=15000
|
||||||
RECONNECT_TIMEOUT_MS=5000
|
RECONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
|
# Voice Recording Selection
|
||||||
|
# VOICE_GUILD_ID falls back to legacy GUILD_ID when omitted.
|
||||||
|
GUILD_ID=legacy_voice_guild_id
|
||||||
|
VOICE_GUILD_ID=voice_guild_id
|
||||||
|
VOICE_CHANNEL_ID=voice_channel_id
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Moderation Configuration
|
# Moderation Configuration
|
||||||
MONITOR_GUILD_ID=your_guild_id_here
|
# TEXT_GUILD_ID falls back to legacy MONITOR_GUILD_ID when omitted.
|
||||||
|
MONITOR_GUILD_ID=legacy_text_guild_id
|
||||||
|
TEXT_GUILD_ID=text_guild_id
|
||||||
|
TEXT_CHANNEL_ID=text_channel_id
|
||||||
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
||||||
ATTACHMENT_MAX_SIZE_MB=100
|
ATTACHMENT_MAX_SIZE_MB=100
|
||||||
|
|||||||
1291
docs/superpowers/plans/2026-05-15-media-music-phase-1.md
Normal file
1291
docs/superpowers/plans/2026-05-15-media-music-phase-1.md
Normal file
File diff suppressed because it is too large
Load Diff
466
docs/superpowers/plans/2026-05-15-split-text-voice-selection.md
Normal file
466
docs/superpowers/plans/2026-05-15-split-text-voice-selection.md
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
# Split Text Voice Selection Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Separate text moderation guild/channel selection from voice recording guild/channel selection in config, backend state, and dashboard UI.
|
||||||
|
|
||||||
|
**Architecture:** Add explicit text and voice config keys while keeping legacy `MONITOR_GUILD_ID` and `GUILD_ID` as fallbacks. Split shared UI state into `selectedTextGuild`/`selectedTextChannel` and `selectedVoiceGuild`/`selectedVoiceChannel`, with backward-compatible migration from old persisted `selectedGuild`. Update capture/backlog to use text-specific settings and voice routes to update only voice-specific state.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Zod config, Express routes, Discord selfbot client, Vitest, static dashboard JavaScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/config.ts`: add `TEXT_GUILD_ID`, `TEXT_CHANNEL_ID`, `VOICE_GUILD_ID`; derive effective text/voice IDs with legacy fallbacks.
|
||||||
|
- Modify `.env.example`: document split text/voice configuration.
|
||||||
|
- Modify `src/moderation/messageCapture.ts`: filter live capture by effective text guild and optional text channel.
|
||||||
|
- Modify `src/moderation/backlogSync.ts`: use effective text guild and optional text channel for readiness/on-demand sync.
|
||||||
|
- Modify `src/webserver.ts`: change `SharedUIState` to split text/voice guild fields and migrate old persisted state.
|
||||||
|
- Modify `src/routes/uiStateRoutes.ts`: update shared UI state type.
|
||||||
|
- Modify `src/routes/voiceRoutes.ts`: patch `selectedVoiceGuild` only on connect/disconnect.
|
||||||
|
- Modify `public/index.html`: add separate voice guild select and text guild select behavior.
|
||||||
|
- Tests: `tests/config.test.ts`, `tests/moderation/messageCapture.test.ts`, and a new UI state route/unit test if needed.
|
||||||
|
|
||||||
|
## Task 1: Split Config Defaults
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.ts`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
- Test: `tests/config.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing config tests**
|
||||||
|
|
||||||
|
Add tests to `tests/config.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("derives split text and voice guild defaults from legacy config", async () => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
DISCORD_TOKEN: "token",
|
||||||
|
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||||
|
GUILD_ID: "legacy-voice-guild",
|
||||||
|
VOICE_CHANNEL_ID: "voice-channel",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../src/config");
|
||||||
|
const config = loadConfig(process.env);
|
||||||
|
|
||||||
|
expect(config.TEXT_GUILD_ID).toBeUndefined();
|
||||||
|
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild");
|
||||||
|
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild");
|
||||||
|
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit split text and voice config before legacy values", async () => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
DISCORD_TOKEN: "token",
|
||||||
|
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||||
|
GUILD_ID: "legacy-voice-guild",
|
||||||
|
TEXT_GUILD_ID: "text-guild",
|
||||||
|
TEXT_CHANNEL_ID: "text-channel",
|
||||||
|
VOICE_GUILD_ID: "voice-guild",
|
||||||
|
VOICE_CHANNEL_ID: "voice-channel",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../src/config");
|
||||||
|
const config = loadConfig(process.env);
|
||||||
|
|
||||||
|
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
|
||||||
|
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||||
|
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run config tests red**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/config.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `EFFECTIVE_TEXT_GUILD_ID` and `EFFECTIVE_VOICE_GUILD_ID` do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add split config fields and derived values**
|
||||||
|
|
||||||
|
In `src/config.ts`, add schema fields near legacy guild config:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||||
|
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||||
|
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `loadConfig` to parse then return derived values:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const parsed = configSchema.parse(env);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `AppConfig` to include derived fields:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type AppConfig = z.infer<typeof configSchema> & {
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `.env.example`**
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Text moderation capture target. Falls back to MONITOR_GUILD_ID for compatibility.
|
||||||
|
TEXT_GUILD_ID=
|
||||||
|
TEXT_CHANNEL_ID=
|
||||||
|
|
||||||
|
# Voice recording default target. Falls back to GUILD_ID for compatibility.
|
||||||
|
VOICE_GUILD_ID=
|
||||||
|
VOICE_CHANNEL_ID=
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep existing legacy keys with notes rather than deleting them.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run config tests green**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/config.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 2: Apply Text Capture Guild/Channel Filtering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/moderation/messageCapture.ts`
|
||||||
|
- Modify: `src/moderation/backlogSync.ts`
|
||||||
|
- Test: `tests/moderation/messageCapture.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing channel filter test**
|
||||||
|
|
||||||
|
In `tests/moderation/messageCapture.test.ts`, mock config before importing `captureMessage` if needed or add a new test file `tests/moderation/messageCaptureFilter.test.ts` that imports a new exported helper.
|
||||||
|
|
||||||
|
Preferred helper test: create `tests/moderation/messageCaptureFilter.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldCaptureMessageLocation } from "../../src/moderation/messageCapture";
|
||||||
|
|
||||||
|
describe("shouldCaptureMessageLocation", () => {
|
||||||
|
it("matches only configured text guild and optional channel", () => {
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-1", channelId: "channel-2" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-2", channelId: "channel-1" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run filter test red**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/moderation/messageCaptureFilter.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `shouldCaptureMessageLocation` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add capture filter helper**
|
||||||
|
|
||||||
|
In `src/moderation/messageCapture.ts`, export:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface TextCaptureTarget {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageLocationInput {
|
||||||
|
guildId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCaptureMessageLocation(
|
||||||
|
message: MessageLocationInput,
|
||||||
|
target: TextCaptureTarget,
|
||||||
|
): boolean {
|
||||||
|
if (!message.guildId || message.guildId !== target.guildId) return false;
|
||||||
|
if (target.channelId && message.channelId !== target.channelId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace event checks:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (
|
||||||
|
!shouldCaptureMessageLocation(message, {
|
||||||
|
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
||||||
|
channelId: config.TEXT_CHANNEL_ID,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same helper for `messageUpdate` and `messageDelete`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update backlog sync config**
|
||||||
|
|
||||||
|
In `src/moderation/backlogSync.ts`, replace readiness checks with `config.EFFECTIVE_TEXT_GUILD_ID` and log names with `TEXT_GUILD_ID`. If `config.TEXT_CHANNEL_ID` is present in `syncBacklogMessages`, verify the channel exists and call `syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID)` instead of only logging readiness.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused moderation tests**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/moderation/messageCapture.test.ts tests/moderation/messageCaptureFilter.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 3: Split Shared UI State
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/webserver.ts`
|
||||||
|
- Modify: `src/routes/uiStateRoutes.ts`
|
||||||
|
- Modify: `src/routes/voiceRoutes.ts`
|
||||||
|
- Test: create `tests/routes/uiStateRoutes.test.ts` if no existing route test fits.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write state migration test**
|
||||||
|
|
||||||
|
Create `tests/routes/uiStateRoutes.test.ts` with a pure helper import if extracted. Add helper in Task 3 implementation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeSharedUIState } from "../../src/webserver";
|
||||||
|
|
||||||
|
describe("normalizeSharedUIState", () => {
|
||||||
|
it("migrates legacy selectedGuild into split text and voice guilds", () => {
|
||||||
|
expect(
|
||||||
|
normalizeSharedUIState({
|
||||||
|
selectedGuild: "legacy-guild",
|
||||||
|
selectedVoiceChannel: "voice-channel",
|
||||||
|
selectedTextChannel: "text-channel",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
selectedVoiceGuild: "legacy-guild",
|
||||||
|
selectedVoiceChannel: "voice-channel",
|
||||||
|
selectedTextGuild: "legacy-guild",
|
||||||
|
selectedTextChannel: "text-channel",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run state test red**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `normalizeSharedUIState` does not exist/export.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update shared state types**
|
||||||
|
|
||||||
|
In `src/routes/uiStateRoutes.ts` and `src/webserver.ts`, replace `selectedGuild` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selectedVoiceGuild: string;
|
||||||
|
selectedVoiceChannel: string;
|
||||||
|
selectedTextGuild: string;
|
||||||
|
selectedTextChannel: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep request patch compatibility by allowing `selectedGuild?: string` in the normalization helper input.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add normalizer and use it after persistence load**
|
||||||
|
|
||||||
|
In `src/webserver.ts`, export:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function normalizeSharedUIState(value: Partial<SharedUIState> & { selectedGuild?: string }): SharedUIState {
|
||||||
|
const legacyGuild = value.selectedGuild ?? "";
|
||||||
|
return {
|
||||||
|
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild,
|
||||||
|
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||||
|
selectedTextGuild: value.selectedTextGuild ?? legacyGuild,
|
||||||
|
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||||
|
activeTab: value.activeTab === "text" ? "text" : "voice",
|
||||||
|
isListening: value.isListening ?? false,
|
||||||
|
isStreaming: value.isStreaming ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it in `initializeSharedUIState()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sharedUIState = normalizeSharedUIState(
|
||||||
|
await getPersistedValue("web-ui-state", defaultSharedUIState),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `patchSharedUIState` to accept `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel`; if legacy `selectedGuild` arrives, set both guild fields.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update voice route patches**
|
||||||
|
|
||||||
|
In `src/routes/voiceRoutes.ts`, connect patch becomes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selectedVoiceGuild: guildId,
|
||||||
|
selectedVoiceChannel: channelId,
|
||||||
|
```
|
||||||
|
|
||||||
|
Disconnect clears only:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selectedVoiceGuild: "",
|
||||||
|
selectedVoiceChannel: "",
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not clear text guild/channel on voice disconnect.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run state tests**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 4: Update Static Dashboard Selection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace state fields**
|
||||||
|
|
||||||
|
Change JS state fields:
|
||||||
|
|
||||||
|
```js
|
||||||
|
selectedVoiceGuild: '',
|
||||||
|
selectedVoiceChannel: '',
|
||||||
|
selectedTextGuild: '',
|
||||||
|
selectedTextChannel: '',
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove direct reliance on `selectedGuild` except migration when applying server state.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add separate DOM selectors**
|
||||||
|
|
||||||
|
In the UI markup, provide separate select elements for voice guild and text guild. Use IDs:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<select id="voiceGuildSelect"></select>
|
||||||
|
<select id="channelSelect"></select>
|
||||||
|
<select id="textGuildSelect"></select>
|
||||||
|
<select id="channelFilter"></select>
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `el` map to use `voiceGuildSelect` and `textGuildSelect`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Split channel loading functions**
|
||||||
|
|
||||||
|
Replace `loadChannels(guildId)` with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function loadVoiceChannels(guildId) {
|
||||||
|
if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel');
|
||||||
|
const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`);
|
||||||
|
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
|
||||||
|
if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTextChannels(guildId) {
|
||||||
|
if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel');
|
||||||
|
const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`);
|
||||||
|
renderOptions(el.channelFilter, watchChannels, 'Select channel');
|
||||||
|
if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel;
|
||||||
|
apiRequest(`/api/guilds/${guildId}/threads`)
|
||||||
|
.then((threads) => {
|
||||||
|
appendOptions(el.channelFilter, threads);
|
||||||
|
if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel;
|
||||||
|
})
|
||||||
|
.catch((error) => showError(`Thread discovery failed: ${error.message}`));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Split state application**
|
||||||
|
|
||||||
|
In `applyServerState`, compute:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
|
||||||
|
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
|
||||||
|
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
|
||||||
|
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
|
||||||
|
```
|
||||||
|
|
||||||
|
Load voice channels only when voice guild changes; load text channels only when text guild changes. Backlog sync uses `state.selectedTextGuild`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Split event listeners**
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```js
|
||||||
|
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
|
||||||
|
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
||||||
|
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
||||||
|
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual UI verification**
|
||||||
|
|
||||||
|
Run: `pnpm run build`
|
||||||
|
|
||||||
|
Expected: PASS. Then start the app if credentials are available and verify selecting voice guild does not reset text guild/channel and selecting text guild does not reset voice guild/channel.
|
||||||
|
|
||||||
|
## Task 5: Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No planned edits unless verification fails.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run lint**
|
||||||
|
|
||||||
|
Run: `pnpm run lint`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm run typecheck`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `pnpm run test`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run build**
|
||||||
|
|
||||||
|
Run: `pnpm run build`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Inspect status**
|
||||||
|
|
||||||
|
Run: `git status --short`
|
||||||
|
|
||||||
|
Expected: only intended files changed.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: config split is Task 1; capture/backlog filtering is Task 2; backend UI state split is Task 3; dashboard split is Task 4; verification is Task 5.
|
||||||
|
- Placeholder scan: no TBD/TODO/fill-in steps remain.
|
||||||
|
- Type consistency: split fields use `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel` consistently.
|
||||||
110
docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md
Normal file
110
docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Media Music Phase 1 Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a first media playback phase focused on play music: users can queue, play, skip, and stop audio sources from the dashboard while preserving the existing Discord voice recorder, browser microphone transmit, and moderation capture flows.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Phase 1 implements audio-only playback and queue control. Share screen/video streaming is intentionally reserved for phase 2, but the controller shape should leave room for a later `screen` mode using the already vendored `@dank074/discord-video-stream` APIs seen in `MythEclipse/StreamBot`.
|
||||||
|
|
||||||
|
## Recommended Architecture
|
||||||
|
|
||||||
|
Create a small media subsystem under `src/media/`:
|
||||||
|
|
||||||
|
- `mediaTypes.ts` defines `MediaMode`, `MediaQueueItem`, `MediaState`, and request/response types.
|
||||||
|
- `mediaQueue.ts` owns in-memory queue operations: add, current, next, remove current, clear, snapshot.
|
||||||
|
- `mediaResolver.ts` resolves initial supported sources. Phase 1 should support direct HTTP(S) URLs and local file paths. YouTube/search can be added later because it requires adding or wrapping yt-dlp behavior.
|
||||||
|
- `musicPlayer.ts` converts a media source to Ogg Opus using ffmpeg and feeds the existing `discordPlayer.playStream()`.
|
||||||
|
- `mediaController.ts` coordinates queue state, voice connection assumptions, play/skip/stop, and WebSocket broadcast state.
|
||||||
|
|
||||||
|
The existing `VoiceController` remains the owner of joining/leaving voice channels. Phase 1 does not create a second voice connection path. Music playback requires the bot to already be connected through the existing voice UI or `/api/connect`; otherwise the media route returns `409 VOICE_NOT_CONNECTED`.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Browser submits a source to `/api/media/queue` with `{ source }`.
|
||||||
|
2. `mediaResolver` validates and resolves the source into `{ source, title, kind }`.
|
||||||
|
3. `mediaQueue` appends a `MediaQueueItem`.
|
||||||
|
4. If no item is playing, `mediaController` starts playback of the current queue item.
|
||||||
|
5. `musicPlayer` spawns ffmpeg and outputs Ogg Opus to `discordPlayer.playStream()`.
|
||||||
|
6. When playback finishes, the controller removes the completed item and starts the next item.
|
||||||
|
7. State changes broadcast over the existing moderation broadcaster as a JSON WebSocket event, or via a small media broadcaster wrapper if that keeps types cleaner.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
Add `src/routes/mediaRoutes.ts` mounted under `/api`:
|
||||||
|
|
||||||
|
- `GET /api/media/status` returns `{ playing, current, queue }`.
|
||||||
|
- `POST /api/media/queue` accepts `{ source: string }`, queues it, and returns the updated state.
|
||||||
|
- `POST /api/media/skip` skips current item and starts the next if present.
|
||||||
|
- `POST /api/media/stop` stops playback and clears the queue.
|
||||||
|
|
||||||
|
All routes should use `AppError` for boundary validation. Empty source returns `400 MISSING_MEDIA_SOURCE`. No voice connection returns `409 VOICE_NOT_CONNECTED`.
|
||||||
|
|
||||||
|
## Dashboard Design
|
||||||
|
|
||||||
|
Add a compact Media card to the existing voice tab for phase 1:
|
||||||
|
|
||||||
|
- Source input: URL or local path.
|
||||||
|
- Buttons: Queue/Play, Skip, Stop.
|
||||||
|
- Current item label and queue list.
|
||||||
|
|
||||||
|
Do not add a separate full media tab yet. The voice tab already owns voice channel selection and connection state, so colocating music controls there reduces user confusion.
|
||||||
|
|
||||||
|
## Playback Details
|
||||||
|
|
||||||
|
Use ffmpeg directly or the existing `src/audio/ffmpegProcess.ts` helper if it already fits. The target stream should be Ogg Opus because `DiscordPlayer.playStream()` currently expects `StreamType.OggOpus`.
|
||||||
|
|
||||||
|
Recommended ffmpeg output shape:
|
||||||
|
|
||||||
|
- Input: local file or HTTP(S) URL.
|
||||||
|
- Output format: `ogg`.
|
||||||
|
- Audio codec: `libopus`.
|
||||||
|
- Sample rate: `48000`.
|
||||||
|
- Channels: `2`.
|
||||||
|
|
||||||
|
The controller owns an `AbortController` or child process handle so skip/stop can terminate ffmpeg. Stop must also call `discordPlayer.stop()` so the audio player releases the current resource.
|
||||||
|
|
||||||
|
## Concurrency Rules
|
||||||
|
|
||||||
|
- Only one media item plays at a time.
|
||||||
|
- Browser microphone transmit and music playback both use `discordPlayer`; phase 1 should disable music start while `isStreaming` is true, or stop browser transmit before playback. Prefer returning `409 BROWSER_STREAM_ACTIVE` to avoid surprising the user.
|
||||||
|
- Voice recording can continue while music plays because recording uses the receiver pipeline and music uses the player pipeline.
|
||||||
|
- Skip is serialized: concurrent skip calls should return the same resulting state or reject with `409 MEDIA_SKIP_IN_PROGRESS`.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Unsupported source format: `400 UNSUPPORTED_MEDIA_SOURCE`.
|
||||||
|
- ffmpeg spawn failure: current item becomes failed, playback advances to the next queued item if present.
|
||||||
|
- ffmpeg runtime failure: log stderr summary, mark item failed, advance queue.
|
||||||
|
- Stop is idempotent: stopping while idle returns current idle state.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Unit tests should cover:
|
||||||
|
|
||||||
|
- Queue add/next/remove/clear behavior.
|
||||||
|
- Resolver accepts HTTP(S) URLs and existing local paths, rejects empty/unsupported input.
|
||||||
|
- Controller rejects playback when voice is not connected.
|
||||||
|
- Controller starts next item after completion.
|
||||||
|
- Skip aborts current playback and advances queue.
|
||||||
|
- Routes validate payloads and call controller methods.
|
||||||
|
|
||||||
|
Manual verification should cover:
|
||||||
|
|
||||||
|
- Connect to a voice channel, queue a short audio URL or local file, hear playback in Discord.
|
||||||
|
- Queue two items, confirm automatic advance.
|
||||||
|
- Skip moves to the next item.
|
||||||
|
- Stop clears playback and queue.
|
||||||
|
- Existing voice recording and text moderation still work after media playback.
|
||||||
|
|
||||||
|
## Phase 2 Compatibility
|
||||||
|
|
||||||
|
Phase 2 can add `MediaMode = "screen"` and a `screenSharePlayer.ts` using StreamBot's pattern:
|
||||||
|
|
||||||
|
- `new Streamer(client)`
|
||||||
|
- `streamer.joinVoice(guildId, channelId)` only if phase 2 decides to own its own connection path
|
||||||
|
- `prepareStream(source, videoOptions, signal)`
|
||||||
|
- `playStream(output, streamer, { type: "go-live" }, signal)`
|
||||||
|
|
||||||
|
Phase 1 should not instantiate `Streamer`; it should only reserve type and controller seams so adding screen share later does not rewrite queue/status APIs.
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<nav class="tab-panel">
|
<nav class="tab-panel">
|
||||||
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
|
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
|
||||||
<div class="filter-row"><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
<div class="filter-row"><span>Text Guild</span><select id="textGuildSelect"><option value="">Select guild</option></select><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="errorBox" class="error"></div>
|
<div id="errorBox" class="error"></div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="voice-layout">
|
<div class="voice-layout">
|
||||||
<div class="content-card">
|
<div class="content-card">
|
||||||
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
||||||
<div class="field-group"><label for="guildSelect">Guild</label><select id="guildSelect"><option value="">Select guild</option></select></div>
|
<div class="field-group"><label for="voiceGuildSelect">Voice Guild</label><select id="voiceGuildSelect"><option value="">Select guild</option></select></div>
|
||||||
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
||||||
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
||||||
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
||||||
@@ -43,6 +43,12 @@
|
|||||||
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
|
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
|
||||||
<div class="visualizer" id="visualizer"></div>
|
<div class="visualizer" id="visualizer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="content-card">
|
||||||
|
<div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
|
||||||
|
<div class="field-group"><label for="mediaSourceInput">Music URL / file path</label><input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3"></div>
|
||||||
|
<div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
|
||||||
|
<div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
|
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -53,8 +59,9 @@
|
|||||||
<script>
|
<script>
|
||||||
const state = {
|
const state = {
|
||||||
socket: null,
|
socket: null,
|
||||||
selectedGuild: '',
|
selectedVoiceGuild: '',
|
||||||
selectedVoiceChannel: '',
|
selectedVoiceChannel: '',
|
||||||
|
selectedTextGuild: '',
|
||||||
selectedTextChannel: '',
|
selectedTextChannel: '',
|
||||||
activeTab: 'voice',
|
activeTab: 'voice',
|
||||||
text: [],
|
text: [],
|
||||||
@@ -67,11 +74,12 @@
|
|||||||
processor: null,
|
processor: null,
|
||||||
userTimelines: new Map(),
|
userTimelines: new Map(),
|
||||||
applyingServerState: false,
|
applyingServerState: false,
|
||||||
|
media: { playing: false, current: null, queue: [] },
|
||||||
};
|
};
|
||||||
const SAMPLE_RATE = 24000;
|
const SAMPLE_RATE = 24000;
|
||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
const el = {
|
const el = {
|
||||||
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), guildSelect: document.getElementById('guildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList')
|
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), voiceGuildSelect: document.getElementById('voiceGuildSelect'), textGuildSelect: document.getElementById('textGuildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList'), mediaSourceInput: document.getElementById('mediaSourceInput'), mediaStatus: document.getElementById('mediaStatus'), queueMediaBtn: document.getElementById('queueMediaBtn'), skipMediaBtn: document.getElementById('skipMediaBtn'), stopMediaBtn: document.getElementById('stopMediaBtn'), mediaQueueList: document.getElementById('mediaQueueList')
|
||||||
};
|
};
|
||||||
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
||||||
const bars = [...document.querySelectorAll('.bar')];
|
const bars = [...document.querySelectorAll('.bar')];
|
||||||
@@ -85,38 +93,45 @@
|
|||||||
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
||||||
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
||||||
|
|
||||||
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } }
|
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.voiceGuildSelect, guilds, 'Select guild'); renderOptions(el.textGuildSelect, guilds, 'Select guild'); if (state.selectedVoiceGuild) { el.voiceGuildSelect.value = state.selectedVoiceGuild; await loadVoiceChannels(state.selectedVoiceGuild); } if (state.selectedTextGuild) { el.textGuildSelect.value = state.selectedTextGuild; await loadTextChannels(state.selectedTextGuild); } }
|
||||||
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
async function loadVoiceChannels(guildId) { if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel'); const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; }
|
||||||
|
async function loadTextChannels(guildId) { if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel'); const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
||||||
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
||||||
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
async function connectVoice() { const guildId = el.voiceGuildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
||||||
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
||||||
|
|
||||||
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
||||||
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } }
|
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); if (message.type === 'user_state') return renderUsers(message.users || []); if (message.type === 'message_created') { state.text.unshift(message.data); renderText(); } if (message.type === 'message_updated') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' }); renderText(); } if (message.type === 'message_deleted') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' }); renderText(); } if (message.type === 'attachment_uploaded') fetchText(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } if (message.type === 'media_state') { state.media = message.state; renderMedia(); } }
|
||||||
|
|
||||||
async function applyServerState(next) {
|
async function applyServerState(next) {
|
||||||
if (!next || state.applyingServerState) return;
|
if (!next || state.applyingServerState) return;
|
||||||
state.applyingServerState = true;
|
state.applyingServerState = true;
|
||||||
const guildChanged = next.selectedGuild !== state.selectedGuild;
|
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
|
||||||
|
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
|
||||||
|
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
|
||||||
|
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
|
||||||
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
||||||
state.selectedGuild = next.selectedGuild || '';
|
state.selectedVoiceGuild = nextVoiceGuild;
|
||||||
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
||||||
|
state.selectedTextGuild = nextTextGuild;
|
||||||
state.selectedTextChannel = next.selectedTextChannel || '';
|
state.selectedTextChannel = next.selectedTextChannel || '';
|
||||||
state.activeTab = next.activeTab || 'voice';
|
state.activeTab = next.activeTab || 'voice';
|
||||||
state.isListening = !!next.isListening;
|
state.isListening = !!next.isListening;
|
||||||
state.isStreaming = !!next.isStreaming;
|
state.isStreaming = !!next.isStreaming;
|
||||||
el.guildSelect.value = state.selectedGuild;
|
el.voiceGuildSelect.value = state.selectedVoiceGuild;
|
||||||
if (guildChanged && state.selectedGuild) await loadChannels(state.selectedGuild);
|
el.textGuildSelect.value = state.selectedTextGuild;
|
||||||
|
if (voiceGuildChanged) await loadVoiceChannels(state.selectedVoiceGuild);
|
||||||
|
if (textGuildChanged) await loadTextChannels(state.selectedTextGuild);
|
||||||
el.channelSelect.value = state.selectedVoiceChannel;
|
el.channelSelect.value = state.selectedVoiceChannel;
|
||||||
el.channelFilter.value = state.selectedTextChannel;
|
el.channelFilter.value = state.selectedTextChannel;
|
||||||
applyActiveTab(state.activeTab);
|
applyActiveTab(state.activeTab);
|
||||||
if (textChanged || state.activeTab === 'text') {
|
if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) {
|
||||||
if (state.selectedTextChannel && state.selectedGuild) {
|
await apiRequest('/api/backlog-sync', {
|
||||||
await apiRequest('/api/backlog-sync', {
|
method: 'POST',
|
||||||
method: 'POST',
|
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
|
||||||
body: JSON.stringify({ guildId: state.selectedGuild, channelId: state.selectedTextChannel }),
|
}).catch((error) => showError(`Backlog sync failed: ${error.message}`));
|
||||||
}).catch((error) => logger.warn('Backlog sync failed:', error.message));
|
}
|
||||||
}
|
if (textChanged || textGuildChanged || state.activeTab === 'text') {
|
||||||
await fetchText().catch((error) => showError(error.message));
|
await fetchText().catch((error) => showError(error.message));
|
||||||
}
|
}
|
||||||
await reconcileListenState();
|
await reconcileListenState();
|
||||||
@@ -142,16 +157,27 @@
|
|||||||
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
|
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
|
||||||
|
|
||||||
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
|
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
|
||||||
el.guildSelect.addEventListener('change', () => postUIState({ selectedGuild: el.guildSelect.value, selectedVoiceChannel: '', selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
|
||||||
|
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
||||||
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
||||||
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
||||||
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
||||||
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
|
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
|
||||||
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
|
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
|
||||||
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
||||||
|
|
||||||
|
async function fetchMediaStatus() { state.media = await apiRequest('/api/media/status'); renderMedia(); }
|
||||||
|
async function queueMedia() { const source = el.mediaSourceInput.value.trim(); if (!source) return showError('Enter a music URL or local file path'); state.media = await apiRequest('/api/media/queue', { method: 'POST', body: JSON.stringify({ source }) }); el.mediaSourceInput.value = ''; renderMedia(); }
|
||||||
|
async function skipMedia() { state.media = await apiRequest('/api/media/skip', { method: 'POST' }); renderMedia(); }
|
||||||
|
async function stopMedia() { state.media = await apiRequest('/api/media/stop', { method: 'POST' }); renderMedia(); }
|
||||||
|
function renderMedia() { el.mediaQueueList.replaceChildren(); const current = state.media.current; el.mediaStatus.textContent = current ? `Playing ${current.title}` : 'Idle'; if (current) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = `Now: ${current.title}`; el.mediaQueueList.appendChild(item); } for (const queued of state.media.queue || []) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = queued.title; el.mediaQueueList.appendChild(item); } if (!current && (!state.media.queue || state.media.queue.length === 0)) appendEmpty(el.mediaQueueList, 'No media queued'); }
|
||||||
|
|
||||||
|
el.queueMediaBtn.addEventListener('click', () => queueMedia().catch((error) => showError(error.message)));
|
||||||
|
el.skipMediaBtn.addEventListener('click', () => skipMedia().catch((error) => showError(error.message)));
|
||||||
|
el.stopMediaBtn.addEventListener('click', () => stopMedia().catch((error) => showError(error.message)));
|
||||||
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
|
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message));
|
||||||
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const configSchema = z
|
|||||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||||
GUILD_ID: z.string().min(1).optional(),
|
GUILD_ID: z.string().min(1).optional(),
|
||||||
|
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||||
|
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||||
|
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||||
VERBOSE: z
|
VERBOSE: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -98,11 +101,19 @@ const configSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema> & {
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
try {
|
try {
|
||||||
return configSchema.parse(env);
|
const parsed = configSchema.parse(env);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||||
|
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
const messages = error.issues
|
const messages = error.issues
|
||||||
|
|||||||
139
src/media/mediaController.ts
Normal file
139
src/media/mediaController.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { AppError } from "../errors";
|
||||||
|
import { discordPlayer } from "../player";
|
||||||
|
import { MediaQueue } from "./mediaQueue";
|
||||||
|
import { resolveMediaSource } from "./mediaResolver";
|
||||||
|
import type {
|
||||||
|
MediaState,
|
||||||
|
MusicPlayback,
|
||||||
|
MusicPlayer,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
import { createMusicPlayer } from "./musicPlayer";
|
||||||
|
|
||||||
|
export interface MediaControllerDependencies {
|
||||||
|
isVoiceConnected?: () => boolean;
|
||||||
|
isBrowserStreaming?: () => boolean;
|
||||||
|
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
||||||
|
musicPlayer?: MusicPlayer;
|
||||||
|
onStateChange?: (state: MediaState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaController {
|
||||||
|
private readonly queueStore = new MediaQueue();
|
||||||
|
private readonly musicPlayer: MusicPlayer;
|
||||||
|
private playback: MusicPlayback | null = null;
|
||||||
|
private playbackToken = 0;
|
||||||
|
private skipInProgress = false;
|
||||||
|
|
||||||
|
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||||
|
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): MediaState {
|
||||||
|
const snapshot = this.queueStore.snapshot();
|
||||||
|
return {
|
||||||
|
playing: snapshot.current?.status === "playing",
|
||||||
|
...snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async queue(source: string): Promise<MediaState> {
|
||||||
|
this.assertCanStart();
|
||||||
|
const resolved = await (
|
||||||
|
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||||
|
)(source);
|
||||||
|
this.queueStore.add(resolved);
|
||||||
|
this.startNextIfIdle();
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async skip(): Promise<MediaState> {
|
||||||
|
if (this.skipInProgress) {
|
||||||
|
throw new AppError(
|
||||||
|
"Skip already in progress",
|
||||||
|
"MEDIA_SKIP_IN_PROGRESS",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.skipInProgress = true;
|
||||||
|
try {
|
||||||
|
this.playbackToken++;
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
this.queueStore.completeCurrent();
|
||||||
|
this.startNextIfIdle();
|
||||||
|
return this.emitState();
|
||||||
|
} finally {
|
||||||
|
this.skipInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<MediaState> {
|
||||||
|
this.playbackToken++;
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
this.queueStore.clear();
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanStart(): void {
|
||||||
|
const isVoiceConnected =
|
||||||
|
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
|
||||||
|
if (!isVoiceConnected()) {
|
||||||
|
throw new AppError(
|
||||||
|
"Connect to a voice channel before playing media",
|
||||||
|
"VOICE_NOT_CONNECTED",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dependencies.isBrowserStreaming?.()) {
|
||||||
|
throw new AppError(
|
||||||
|
"Stop browser microphone streaming before playing media",
|
||||||
|
"BROWSER_STREAM_ACTIVE",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startNextIfIdle(): void {
|
||||||
|
if (this.playback) return;
|
||||||
|
const item = this.queueStore.startNext();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const token = ++this.playbackToken;
|
||||||
|
try {
|
||||||
|
this.playback = this.musicPlayer.play(item);
|
||||||
|
} catch {
|
||||||
|
this.queueStore.failCurrent();
|
||||||
|
this.playback = null;
|
||||||
|
this.startNextIfIdle();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playback.done.then(
|
||||||
|
() => this.finishCurrent(token, false),
|
||||||
|
() => this.finishCurrent(token, true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishCurrent(token: number, failed: boolean): void {
|
||||||
|
if (token !== this.playbackToken) return;
|
||||||
|
this.playback = null;
|
||||||
|
if (failed) {
|
||||||
|
this.queueStore.failCurrent();
|
||||||
|
} else {
|
||||||
|
this.queueStore.completeCurrent();
|
||||||
|
}
|
||||||
|
this.startNextIfIdle();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitState(): MediaState {
|
||||||
|
const state = this.getState();
|
||||||
|
this.dependencies.onStateChange?.(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/media/mediaQueue.ts
Normal file
59
src/media/mediaQueue.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
MediaQueueItem,
|
||||||
|
MediaState,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
|
||||||
|
export class MediaQueue {
|
||||||
|
private current: MediaQueueItem | null = null;
|
||||||
|
private readonly items: MediaQueueItem[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly createId: () => string = () => crypto.randomUUID(),
|
||||||
|
private readonly now = () => Date.now(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem {
|
||||||
|
const item: MediaQueueItem = {
|
||||||
|
id: this.createId(),
|
||||||
|
mode: "music",
|
||||||
|
requestedBy,
|
||||||
|
addedAt: this.now(),
|
||||||
|
status: "queued",
|
||||||
|
...source,
|
||||||
|
};
|
||||||
|
this.items.push(item);
|
||||||
|
return { ...item };
|
||||||
|
}
|
||||||
|
|
||||||
|
startNext(): MediaQueueItem | null {
|
||||||
|
if (this.current) return { ...this.current };
|
||||||
|
const next = this.items.shift();
|
||||||
|
if (!next) return null;
|
||||||
|
this.current = { ...next, status: "playing" };
|
||||||
|
return { ...this.current };
|
||||||
|
}
|
||||||
|
|
||||||
|
completeCurrent(): void {
|
||||||
|
this.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
failCurrent(): MediaQueueItem | null {
|
||||||
|
if (!this.current) return null;
|
||||||
|
const failed = { ...this.current, status: "failed" as const };
|
||||||
|
this.current = null;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.current = null;
|
||||||
|
this.items.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): Pick<MediaState, "current" | "queue"> {
|
||||||
|
return {
|
||||||
|
current: this.current ? { ...this.current } : null,
|
||||||
|
queue: this.items.map((item) => ({ ...item })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/media/mediaResolver.ts
Normal file
53
src/media/mediaResolver.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { existsSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { AppError } from "../errors";
|
||||||
|
import type { ResolvedMediaSource } from "./mediaTypes";
|
||||||
|
|
||||||
|
export async function resolveMediaSource(
|
||||||
|
input: string,
|
||||||
|
): Promise<ResolvedMediaSource> {
|
||||||
|
const source = input.trim();
|
||||||
|
if (!source) {
|
||||||
|
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlSource = resolveUrlSource(source);
|
||||||
|
if (urlSource) return urlSource;
|
||||||
|
|
||||||
|
const localPath = path.resolve(source);
|
||||||
|
if (existsSync(localPath) && statSync(localPath).isFile()) {
|
||||||
|
return {
|
||||||
|
source: localPath,
|
||||||
|
title: path.basename(localPath),
|
||||||
|
kind: "local",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AppError(
|
||||||
|
"Media source must be an HTTP(S) URL or existing local file",
|
||||||
|
"UNSUPPORTED_MEDIA_SOURCE",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrlSource(source: string): ResolvedMediaSource | null {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(source);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
title: titleFromUrl(url),
|
||||||
|
kind: "url",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromUrl(url: URL): string {
|
||||||
|
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
|
||||||
|
return path.basename(filename) || url.hostname;
|
||||||
|
}
|
||||||
40
src/media/mediaTypes.ts
Normal file
40
src/media/mediaTypes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
export type MediaMode = "music" | "screen";
|
||||||
|
export type MediaSourceKind = "url" | "local";
|
||||||
|
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||||
|
|
||||||
|
export interface ResolvedMediaSource {
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
kind: MediaSourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaQueueItem extends ResolvedMediaSource {
|
||||||
|
id: string;
|
||||||
|
mode: MediaMode;
|
||||||
|
requestedBy: string;
|
||||||
|
addedAt: number;
|
||||||
|
status: MediaQueueItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaState {
|
||||||
|
playing: boolean;
|
||||||
|
current: MediaQueueItem | null;
|
||||||
|
queue: MediaQueueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicPlayback {
|
||||||
|
done: Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicPlayer {
|
||||||
|
play(source: ResolvedMediaSource): MusicPlayback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAudioPlayer {
|
||||||
|
isConnected(): boolean;
|
||||||
|
playStream(stream: Readable): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
79
src/media/musicPlayer.ts
Normal file
79
src/media/musicPlayer.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
|
import { discordPlayer } from "../player";
|
||||||
|
import type {
|
||||||
|
DiscordAudioPlayer,
|
||||||
|
MusicPlayback,
|
||||||
|
MusicPlayer,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
|
||||||
|
export interface MusicPlayerDependencies {
|
||||||
|
spawn?: typeof nodeSpawn;
|
||||||
|
discordPlayer?: DiscordAudioPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMusicPlayer(
|
||||||
|
dependencies: MusicPlayerDependencies = {},
|
||||||
|
): MusicPlayer {
|
||||||
|
const spawn = dependencies.spawn ?? nodeSpawn;
|
||||||
|
const audioPlayer = dependencies.discordPlayer ?? discordPlayer;
|
||||||
|
|
||||||
|
return {
|
||||||
|
play(source: ResolvedMediaSource): MusicPlayback {
|
||||||
|
if (!audioPlayer.isConnected()) {
|
||||||
|
throw new Error("Discord audio player is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
}) as unknown as ChildProcessWithoutNullStreams;
|
||||||
|
proc.stderr.resume();
|
||||||
|
|
||||||
|
audioPlayer.playStream(proc.stdout);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.stdout.on("error", reject);
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
if (code === 0 || stopped) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
done,
|
||||||
|
stop() {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
audioPlayer.stop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFfmpegArgs(source: string): string[] {
|
||||||
|
return [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-i",
|
||||||
|
source,
|
||||||
|
"-vn",
|
||||||
|
"-acodec",
|
||||||
|
"libopus",
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-f",
|
||||||
|
"ogg",
|
||||||
|
"pipe:1",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -54,20 +54,26 @@ async function syncChannelMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncBacklogMessages(client: Client): Promise<void> {
|
export async function syncBacklogMessages(client: Client): Promise<void> {
|
||||||
if (!config.MONITOR_GUILD_ID) {
|
const textGuildId = config.EFFECTIVE_TEXT_GUILD_ID;
|
||||||
logger.warn("MONITOR_GUILD_ID not configured, skipping backlog sync");
|
if (!textGuildId) {
|
||||||
|
logger.warn("TEXT_GUILD_ID not configured, skipping backlog sync");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
|
const guild = client.guilds.cache.get(textGuildId);
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ guildId: config.MONITOR_GUILD_ID },
|
{ guildId: textGuildId },
|
||||||
"Monitor guild not found, skipping backlog sync",
|
"Text guild not found, skipping backlog sync",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.TEXT_CHANNEL_ID) {
|
||||||
|
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ guildId: guild.id },
|
{ guildId: guild.id },
|
||||||
"Backlog sync ready (will sync on-demand per selected channel)",
|
"Backlog sync ready (will sync on-demand per selected channel)",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createChildLogger } from "../logger";
|
|||||||
import type {
|
import type {
|
||||||
AnalysisQueueStatus,
|
AnalysisQueueStatus,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
|
MediaState,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
ModerationWsEvent,
|
ModerationWsEvent,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -72,6 +73,9 @@ export function createBroadcaster() {
|
|||||||
analysisQueueStatus(data: AnalysisQueueStatus) {
|
analysisQueueStatus(data: AnalysisQueueStatus) {
|
||||||
sendJson(clients, { type: "analysis_queue_status", data });
|
sendJson(clients, { type: "analysis_queue_status", data });
|
||||||
},
|
},
|
||||||
|
mediaState(state: MediaState) {
|
||||||
|
sendJson(clients, { type: "media_state", state });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
|||||||
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextCaptureTarget {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageLocationInput {
|
||||||
|
guildId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCaptureMessageLocation(
|
||||||
|
message: MessageLocationInput,
|
||||||
|
target: TextCaptureTarget,
|
||||||
|
): boolean {
|
||||||
|
if (!message.guildId || message.guildId !== target.guildId) return false;
|
||||||
|
if (target.channelId && message.channelId !== target.channelId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextCaptureTarget(): TextCaptureTarget {
|
||||||
|
return {
|
||||||
|
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
||||||
|
channelId: config.TEXT_CHANNEL_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function captureMessage(
|
export async function captureMessage(
|
||||||
message: Message,
|
message: Message,
|
||||||
type: "text" | "edited" | "deleted",
|
type: "text" | "edited" | "deleted",
|
||||||
@@ -110,7 +136,7 @@ export async function captureMessage(
|
|||||||
|
|
||||||
export function registerMessageCapture(client: Client): void {
|
export function registerMessageCapture(client: Client): void {
|
||||||
client.on("messageCreate", async (message) => {
|
client.on("messageCreate", async (message) => {
|
||||||
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
|
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||||
if (message.author?.bot) return;
|
if (message.author?.bot) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +153,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
||||||
if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID)
|
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
|
||||||
return;
|
return;
|
||||||
if (newMessage.author?.bot) return;
|
if (newMessage.author?.bot) return;
|
||||||
|
|
||||||
@@ -166,7 +192,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageDelete", async (message) => {
|
client.on("messageDelete", async (message) => {
|
||||||
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
|
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||||
if (!message.author) return;
|
if (!message.author) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -92,6 +92,27 @@ export interface AnalysisResult {
|
|||||||
analysis: string;
|
analysis: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaMode = "music" | "screen";
|
||||||
|
export type MediaSourceKind = "url" | "local";
|
||||||
|
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||||
|
|
||||||
|
export interface MediaQueueItem {
|
||||||
|
id: string;
|
||||||
|
mode: MediaMode;
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
kind: MediaSourceKind;
|
||||||
|
requestedBy: string;
|
||||||
|
addedAt: number;
|
||||||
|
status: MediaQueueItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaState {
|
||||||
|
playing: boolean;
|
||||||
|
current: MediaQueueItem | null;
|
||||||
|
queue: MediaQueueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export type ModerationWsEvent =
|
export type ModerationWsEvent =
|
||||||
| { type: "ui_state"; state: unknown }
|
| { type: "ui_state"; state: unknown }
|
||||||
| { type: "user_state"; users: unknown[] }
|
| { type: "user_state"; users: unknown[] }
|
||||||
@@ -100,7 +121,8 @@ export type ModerationWsEvent =
|
|||||||
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
||||||
| { type: "message_analyzed"; data: MessageRecord }
|
| { type: "message_analyzed"; data: MessageRecord }
|
||||||
| { type: "attachment_created"; data: AttachmentRecord }
|
| { type: "attachment_created"; data: AttachmentRecord }
|
||||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus };
|
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
||||||
|
| { type: "media_state"; state: MediaState };
|
||||||
|
|
||||||
export interface AnalysisQueueStatus {
|
export interface AnalysisQueueStatus {
|
||||||
queuedConversations: number;
|
queuedConversations: number;
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export class DiscordPlayer {
|
|||||||
this.connection.subscribe(this.player);
|
this.connection.subscribe(this.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isConnected(): boolean {
|
||||||
|
return this.connection !== null;
|
||||||
|
}
|
||||||
|
|
||||||
public playStream(stream: Readable) {
|
public playStream(stream: Readable) {
|
||||||
console.log("[player] Starting new audio stream...");
|
console.log("[player] Starting new audio stream...");
|
||||||
|
|
||||||
|
|||||||
55
src/routes/mediaRoutes.ts
Normal file
55
src/routes/mediaRoutes.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { AppError } from "../errors";
|
||||||
|
import type { MediaController } from "../media/mediaController";
|
||||||
|
|
||||||
|
export type MediaRouteController = Pick<
|
||||||
|
MediaController,
|
||||||
|
"getState" | "queue" | "skip" | "stop"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createMediaRoutes(controller: MediaRouteController): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/media/status", (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(controller.getState());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/media/queue", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { source } = req.body as { source?: string };
|
||||||
|
if (!source) {
|
||||||
|
throw new AppError(
|
||||||
|
"Media source is required",
|
||||||
|
"MISSING_MEDIA_SOURCE",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json(await controller.queue(source));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/media/skip", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await controller.skip());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/media/stop", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await controller.stop());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -6,6 +6,24 @@ import { createChildLogger } from "../logger";
|
|||||||
import { syncSelectedChannelBacklog } from "../moderation/backlogSync";
|
import { syncSelectedChannelBacklog } from "../moderation/backlogSync";
|
||||||
|
|
||||||
const logger = createChildLogger("sync-routes");
|
const logger = createChildLogger("sync-routes");
|
||||||
|
const BACKLOG_SYNC_COOLDOWN_MS = 5 * 60 * 1000;
|
||||||
|
const recentBacklogSyncs = new Map<string, number>();
|
||||||
|
|
||||||
|
export function shouldSkipRecentBacklogSync(
|
||||||
|
guildId: string,
|
||||||
|
channelId: string,
|
||||||
|
now = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
const key = `${guildId}:${channelId}`;
|
||||||
|
const lastSync = recentBacklogSyncs.get(key);
|
||||||
|
if (lastSync && now - lastSync < BACKLOG_SYNC_COOLDOWN_MS) return true;
|
||||||
|
recentBacklogSyncs.set(key, now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRecentBacklogSyncs(): void {
|
||||||
|
recentBacklogSyncs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export function createSyncRoutes(client: Client): Router {
|
export function createSyncRoutes(client: Client): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -26,6 +44,17 @@ export function createSyncRoutes(client: Client): Router {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldSkipRecentBacklogSync(guildId, channelId)) {
|
||||||
|
logger.debug({ guildId, channelId }, "Skipping recent backlog sync");
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
channelId,
|
||||||
|
messagesSync: 0,
|
||||||
|
skipped: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info({ guildId, channelId }, "Starting backlog sync");
|
logger.info({ guildId, channelId }, "Starting backlog sync");
|
||||||
|
|
||||||
const count = await syncSelectedChannelBacklog(
|
const count = await syncSelectedChannelBacklog(
|
||||||
@@ -43,6 +72,7 @@ export function createSyncRoutes(client: Client): Router {
|
|||||||
success: true,
|
success: true,
|
||||||
channelId,
|
channelId,
|
||||||
messagesSync: count,
|
messagesSync: count,
|
||||||
|
skipped: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -5,17 +5,22 @@ import { createChildLogger } from "../logger";
|
|||||||
const logger = createChildLogger("ui-state-routes");
|
const logger = createChildLogger("ui-state-routes");
|
||||||
|
|
||||||
export interface SharedUIState {
|
export interface SharedUIState {
|
||||||
selectedGuild: string;
|
selectedVoiceGuild: string;
|
||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "text";
|
activeTab: "voice" | "text";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||||
|
selectedGuild?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface UIStateRouteOptions {
|
export interface UIStateRouteOptions {
|
||||||
getSharedUIState: () => SharedUIState;
|
getSharedUIState: () => SharedUIState;
|
||||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
patchSharedUIState: (patch: SharedUIStatePatch) => SharedUIState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
||||||
@@ -35,7 +40,7 @@ export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
|||||||
// POST /api/ui-state - Update UI state
|
// POST /api/ui-state - Update UI state
|
||||||
router.post("/ui-state", (req, res, next) => {
|
router.post("/ui-state", (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const patch = req.body as Partial<SharedUIState>;
|
const patch = req.body as SharedUIStatePatch;
|
||||||
const updated = patchSharedUIState(patch);
|
const updated = patchSharedUIState(patch);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function createVoiceRoutes(
|
|||||||
// Update UI state and broadcast to connected clients
|
// Update UI state and broadcast to connected clients
|
||||||
if (patchSharedUIState && broadcaster) {
|
if (patchSharedUIState && broadcaster) {
|
||||||
const updatedState = patchSharedUIState({
|
const updatedState = patchSharedUIState({
|
||||||
selectedGuild: guildId,
|
selectedVoiceGuild: guildId,
|
||||||
selectedVoiceChannel: channelId,
|
selectedVoiceChannel: channelId,
|
||||||
});
|
});
|
||||||
broadcaster.uiState(updatedState);
|
broadcaster.uiState(updatedState);
|
||||||
@@ -150,7 +150,7 @@ export function createVoiceRoutes(
|
|||||||
// Update UI state and broadcast to connected clients
|
// Update UI state and broadcast to connected clients
|
||||||
if (patchSharedUIState && broadcaster) {
|
if (patchSharedUIState && broadcaster) {
|
||||||
const updatedState = patchSharedUIState({
|
const updatedState = patchSharedUIState({
|
||||||
selectedGuild: "",
|
selectedVoiceGuild: "",
|
||||||
selectedVoiceChannel: "",
|
selectedVoiceChannel: "",
|
||||||
});
|
});
|
||||||
broadcaster.uiState(updatedState);
|
broadcaster.uiState(updatedState);
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import * as prism from "prism-media";
|
|||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { AppError } from "./errors";
|
import { AppError } from "./errors";
|
||||||
import { createChildLogger, logger } from "./logger";
|
import { createChildLogger, logger } from "./logger";
|
||||||
|
import { MediaController } from "./media/mediaController";
|
||||||
import { getMetrics, uptimeGauge } from "./metrics";
|
import { getMetrics, uptimeGauge } from "./metrics";
|
||||||
import { createBroadcaster } from "./moderation/broadcaster";
|
import { createBroadcaster } from "./moderation/broadcaster";
|
||||||
import type { ModerationBroadcaster } from "./moderation/types";
|
import type { ModerationBroadcaster } from "./moderation/types";
|
||||||
import { getPersistedValue, setPersistedValue } from "./muxer-queue";
|
import { getPersistedValue, setPersistedValue } from "./muxer-queue";
|
||||||
import { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
import { createAnalysisRoutes } from "./routes/analysisRoutes";
|
import { createAnalysisRoutes } from "./routes/analysisRoutes";
|
||||||
|
import { createMediaRoutes } from "./routes/mediaRoutes";
|
||||||
import { createMessageRoutes } from "./routes/messageRoutes";
|
import { createMessageRoutes } from "./routes/messageRoutes";
|
||||||
import { createSyncRoutes } from "./routes/syncRoutes";
|
import { createSyncRoutes } from "./routes/syncRoutes";
|
||||||
import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
||||||
@@ -37,17 +39,23 @@ type VoiceGlobals = typeof globalThis & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface SharedUIState {
|
interface SharedUIState {
|
||||||
selectedGuild: string;
|
selectedVoiceGuild: string;
|
||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "text";
|
activeTab: "voice" | "text";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||||
|
selectedGuild?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultSharedUIState: SharedUIState = {
|
const defaultSharedUIState: SharedUIState = {
|
||||||
selectedGuild: "",
|
selectedVoiceGuild: "",
|
||||||
selectedVoiceChannel: "",
|
selectedVoiceChannel: "",
|
||||||
|
selectedTextGuild: "",
|
||||||
selectedTextChannel: "",
|
selectedTextChannel: "",
|
||||||
activeTab: "voice",
|
activeTab: "voice",
|
||||||
isListening: false,
|
isListening: false,
|
||||||
@@ -56,21 +64,45 @@ const defaultSharedUIState: SharedUIState = {
|
|||||||
|
|
||||||
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
||||||
|
|
||||||
|
export function normalizeSharedUIState(
|
||||||
|
value: SharedUIStatePatch,
|
||||||
|
): SharedUIState {
|
||||||
|
const legacyGuild = value.selectedGuild ?? "";
|
||||||
|
return {
|
||||||
|
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild,
|
||||||
|
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||||
|
selectedTextGuild: value.selectedTextGuild ?? legacyGuild,
|
||||||
|
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||||
|
activeTab: value.activeTab === "text" ? "text" : "voice",
|
||||||
|
isListening: value.isListening ?? false,
|
||||||
|
isStreaming: value.isStreaming ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function initializeSharedUIState() {
|
async function initializeSharedUIState() {
|
||||||
sharedUIState = await getPersistedValue("web-ui-state", defaultSharedUIState);
|
sharedUIState = normalizeSharedUIState(
|
||||||
|
await getPersistedValue("web-ui-state", defaultSharedUIState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSharedUIState(): SharedUIState {
|
function getSharedUIState(): SharedUIState {
|
||||||
return { ...sharedUIState };
|
return { ...sharedUIState };
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchSharedUIState(patch: Partial<SharedUIState>) {
|
function patchSharedUIState(patch: SharedUIStatePatch) {
|
||||||
if (typeof patch.selectedGuild === "string") {
|
if (typeof patch.selectedGuild === "string") {
|
||||||
sharedUIState.selectedGuild = patch.selectedGuild;
|
sharedUIState.selectedVoiceGuild = patch.selectedGuild;
|
||||||
|
sharedUIState.selectedTextGuild = patch.selectedGuild;
|
||||||
|
}
|
||||||
|
if (typeof patch.selectedVoiceGuild === "string") {
|
||||||
|
sharedUIState.selectedVoiceGuild = patch.selectedVoiceGuild;
|
||||||
}
|
}
|
||||||
if (typeof patch.selectedVoiceChannel === "string") {
|
if (typeof patch.selectedVoiceChannel === "string") {
|
||||||
sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel;
|
sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel;
|
||||||
}
|
}
|
||||||
|
if (typeof patch.selectedTextGuild === "string") {
|
||||||
|
sharedUIState.selectedTextGuild = patch.selectedTextGuild;
|
||||||
|
}
|
||||||
if (typeof patch.selectedTextChannel === "string") {
|
if (typeof patch.selectedTextChannel === "string") {
|
||||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||||
}
|
}
|
||||||
@@ -130,6 +162,12 @@ export async function startWebserver(
|
|||||||
const broadcaster = createBroadcaster();
|
const broadcaster = createBroadcaster();
|
||||||
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
||||||
|
|
||||||
|
const mediaController = new MediaController({
|
||||||
|
isVoiceConnected: () => voiceController.getStatus().connected,
|
||||||
|
isBrowserStreaming: () => sharedUIState.isStreaming,
|
||||||
|
onStateChange: (state) => broadcaster.mediaState(state),
|
||||||
|
});
|
||||||
|
|
||||||
// Security headers. CSP disabled because the current static UI uses inline scripts/styles.
|
// Security headers. CSP disabled because the current static UI uses inline scripts/styles.
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
@@ -204,6 +242,7 @@ export async function startWebserver(
|
|||||||
app.use("/api", createMessageRoutes());
|
app.use("/api", createMessageRoutes());
|
||||||
app.use("/api", createAnalysisRoutes());
|
app.use("/api", createAnalysisRoutes());
|
||||||
app.use("/api", createSyncRoutes(_client));
|
app.use("/api", createSyncRoutes(_client));
|
||||||
|
app.use("/api", createMediaRoutes(mediaController));
|
||||||
|
|
||||||
// Inbound: Discord PCM → tagged chunks → browser
|
// Inbound: Discord PCM → tagged chunks → browser
|
||||||
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
|
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
|
||||||
@@ -339,6 +378,12 @@ export async function startWebserver(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
ws.send(JSON.stringify({ type: "ui_state", state: getSharedUIState() }));
|
ws.send(JSON.stringify({ type: "ui_state", state: getSharedUIState() }));
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "media_state",
|
||||||
|
state: mediaController.getState(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
|
ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
|
||||||
if (!Buffer.isBuffer(data)) return;
|
if (!Buffer.isBuffer(data)) return;
|
||||||
|
|||||||
@@ -29,4 +29,44 @@ describe("loadConfig", () => {
|
|||||||
expect(config.RECORDINGS_DIR).toBe("./recordings");
|
expect(config.RECORDINGS_DIR).toBe("./recordings");
|
||||||
expect(config.NODE_ENV).toBe("test");
|
expect(config.NODE_ENV).toBe("test");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives split text and voice guild defaults from legacy config", async () => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
DISCORD_TOKEN: "token",
|
||||||
|
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||||
|
GUILD_ID: "legacy-voice-guild",
|
||||||
|
VOICE_CHANNEL_ID: "voice-channel",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../src/config");
|
||||||
|
const config = loadConfig(process.env);
|
||||||
|
|
||||||
|
expect(config.TEXT_GUILD_ID).toBeUndefined();
|
||||||
|
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild");
|
||||||
|
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild");
|
||||||
|
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit split text and voice config before legacy values", async () => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
DISCORD_TOKEN: "token",
|
||||||
|
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||||
|
GUILD_ID: "legacy-voice-guild",
|
||||||
|
TEXT_GUILD_ID: "text-guild",
|
||||||
|
TEXT_CHANNEL_ID: "text-channel",
|
||||||
|
VOICE_GUILD_ID: "voice-guild",
|
||||||
|
VOICE_CHANNEL_ID: "voice-channel",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../src/config");
|
||||||
|
const config = loadConfig(process.env);
|
||||||
|
|
||||||
|
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
|
||||||
|
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||||
|
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
214
tests/media/mediaController.test.ts
Normal file
214
tests/media/mediaController.test.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AppError } from "../../src/errors";
|
||||||
|
import { MediaController } from "../../src/media/mediaController";
|
||||||
|
import type {
|
||||||
|
MusicPlayback,
|
||||||
|
MusicPlayer,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "../../src/media/mediaTypes";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<void>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function source(input: string): ResolvedMediaSource {
|
||||||
|
return { source: input, title: input.split("/").pop() || input, kind: "url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MediaController", () => {
|
||||||
|
it("rejects queue playback when voice is not connected", async () => {
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => false,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async () => source("https://example.com/song.mp3"),
|
||||||
|
musicPlayer: { play: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.queue("https://example.com/song.mp3"),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "VOICE_NOT_CONNECTED",
|
||||||
|
statusCode: 409,
|
||||||
|
} satisfies Partial<AppError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects queue playback while browser streaming is active", async () => {
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => true,
|
||||||
|
resolveMediaSource: async () => source("https://example.com/song.mp3"),
|
||||||
|
musicPlayer: { play: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.queue("https://example.com/song.mp3"),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "BROWSER_STREAM_ACTIVE",
|
||||||
|
statusCode: 409,
|
||||||
|
} satisfies Partial<AppError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues and starts the first item", async () => {
|
||||||
|
const done = deferred();
|
||||||
|
const playback: MusicPlayback = { done: done.promise, stop: vi.fn() };
|
||||||
|
const musicPlayer: MusicPlayer = { play: vi.fn(() => playback) };
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async () => source("https://example.com/song.mp3"),
|
||||||
|
musicPlayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await controller.queue("https://example.com/song.mp3");
|
||||||
|
|
||||||
|
expect(state.playing).toBe(true);
|
||||||
|
expect(state.current?.title).toBe("song.mp3");
|
||||||
|
expect(musicPlayer.play).toHaveBeenCalledWith(state.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances to the next item when playback finishes", async () => {
|
||||||
|
const first = deferred();
|
||||||
|
const second = deferred();
|
||||||
|
const musicPlayer: MusicPlayer = {
|
||||||
|
play: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({ done: first.promise, stop: vi.fn() })
|
||||||
|
.mockReturnValueOnce({ done: second.promise, stop: vi.fn() }),
|
||||||
|
};
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.queue("https://example.com/first.mp3");
|
||||||
|
await controller.queue("https://example.com/second.mp3");
|
||||||
|
first.resolve();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(controller.getState().current?.title).toBe("second.mp3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips current playback and starts the next item", async () => {
|
||||||
|
const currentStop = vi.fn();
|
||||||
|
const nextPlayback = deferred();
|
||||||
|
const musicPlayer: MusicPlayer = {
|
||||||
|
play: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
done: new Promise<void>(() => {}),
|
||||||
|
stop: currentStop,
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({ done: nextPlayback.promise, stop: vi.fn() }),
|
||||||
|
};
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer,
|
||||||
|
});
|
||||||
|
await controller.queue("https://example.com/first.mp3");
|
||||||
|
await controller.queue("https://example.com/second.mp3");
|
||||||
|
|
||||||
|
const state = await controller.skip();
|
||||||
|
|
||||||
|
expect(currentStop).toHaveBeenCalled();
|
||||||
|
expect(state.current?.title).toBe("second.mp3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale completion after skip starts the next item", async () => {
|
||||||
|
const first = deferred();
|
||||||
|
const second = deferred();
|
||||||
|
const third = deferred();
|
||||||
|
const musicPlayer: MusicPlayer = {
|
||||||
|
play: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({ done: first.promise, stop: vi.fn() })
|
||||||
|
.mockReturnValueOnce({ done: second.promise, stop: vi.fn() })
|
||||||
|
.mockReturnValueOnce({ done: third.promise, stop: vi.fn() }),
|
||||||
|
};
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer,
|
||||||
|
});
|
||||||
|
await controller.queue("https://example.com/first.mp3");
|
||||||
|
await controller.queue("https://example.com/second.mp3");
|
||||||
|
await controller.queue("https://example.com/third.mp3");
|
||||||
|
|
||||||
|
await controller.skip();
|
||||||
|
first.resolve();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(controller.getState().current?.title).toBe("second.mp3");
|
||||||
|
expect(musicPlayer.play).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances when player throws while starting an item", async () => {
|
||||||
|
const second = deferred();
|
||||||
|
const musicPlayer: MusicPlayer = {
|
||||||
|
play: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
throw new Error("not connected");
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({ done: second.promise, stop: vi.fn() }),
|
||||||
|
};
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.queue("https://example.com/first.mp3");
|
||||||
|
await controller.queue("https://example.com/second.mp3");
|
||||||
|
|
||||||
|
expect(controller.getState().current?.title).toBe("second.mp3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops current playback and clears the queue", async () => {
|
||||||
|
const stop = vi.fn();
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer: {
|
||||||
|
play: vi.fn(() => ({ done: new Promise<void>(() => {}), stop })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await controller.queue("https://example.com/song.mp3");
|
||||||
|
|
||||||
|
const state = await controller.stop();
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalled();
|
||||||
|
expect(state).toEqual({ playing: false, current: null, queue: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits state changes", async () => {
|
||||||
|
const onStateChange = vi.fn();
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer: {
|
||||||
|
play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })),
|
||||||
|
},
|
||||||
|
onStateChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.queue("https://example.com/song.mp3");
|
||||||
|
|
||||||
|
expect(onStateChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ playing: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
tests/media/mediaQueue.test.ts
Normal file
93
tests/media/mediaQueue.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MediaQueue } from "../../src/media/mediaQueue";
|
||||||
|
import type { ResolvedMediaSource } from "../../src/media/mediaTypes";
|
||||||
|
|
||||||
|
function source(
|
||||||
|
overrides: Partial<ResolvedMediaSource> = {},
|
||||||
|
): ResolvedMediaSource {
|
||||||
|
return {
|
||||||
|
source: "https://example.com/audio.ogg",
|
||||||
|
title: "audio.ogg",
|
||||||
|
kind: "url",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MediaQueue", () => {
|
||||||
|
it("adds items with stable queue metadata", () => {
|
||||||
|
const queue = new MediaQueue(
|
||||||
|
() => "item-1",
|
||||||
|
() => 1700000000000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const item = queue.add(source(), "tester");
|
||||||
|
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
id: "item-1",
|
||||||
|
mode: "music",
|
||||||
|
source: "https://example.com/audio.ogg",
|
||||||
|
title: "audio.ogg",
|
||||||
|
kind: "url",
|
||||||
|
requestedBy: "tester",
|
||||||
|
addedAt: 1700000000000,
|
||||||
|
status: "queued",
|
||||||
|
});
|
||||||
|
expect(queue.snapshot()).toEqual({ current: null, queue: [item] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the next queued item as playing", () => {
|
||||||
|
const queue = new MediaQueue(
|
||||||
|
() => "item-1",
|
||||||
|
() => 1700000000000,
|
||||||
|
);
|
||||||
|
const item = queue.add(source(), "tester");
|
||||||
|
|
||||||
|
expect(queue.startNext()).toEqual({ ...item, status: "playing" });
|
||||||
|
expect(queue.snapshot()).toEqual({
|
||||||
|
current: { ...item, status: "playing" },
|
||||||
|
queue: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes current item and starts following item", () => {
|
||||||
|
let id = 0;
|
||||||
|
const queue = new MediaQueue(
|
||||||
|
() => `item-${++id}`,
|
||||||
|
() => 1700000000000,
|
||||||
|
);
|
||||||
|
queue.add(source({ title: "first" }), "tester");
|
||||||
|
queue.add(source({ title: "second" }), "tester");
|
||||||
|
queue.startNext();
|
||||||
|
|
||||||
|
queue.completeCurrent();
|
||||||
|
const next = queue.startNext();
|
||||||
|
|
||||||
|
expect(next?.title).toBe("second");
|
||||||
|
expect(queue.snapshot().queue).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the failed current item", () => {
|
||||||
|
const queue = new MediaQueue(
|
||||||
|
() => "item-1",
|
||||||
|
() => 1700000000000,
|
||||||
|
);
|
||||||
|
const item = queue.add(source(), "tester");
|
||||||
|
queue.startNext();
|
||||||
|
|
||||||
|
expect(queue.failCurrent()).toEqual({ ...item, status: "failed" });
|
||||||
|
expect(queue.snapshot()).toEqual({ current: null, queue: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears current and queued items", () => {
|
||||||
|
const queue = new MediaQueue(
|
||||||
|
() => "item-1",
|
||||||
|
() => 1700000000000,
|
||||||
|
);
|
||||||
|
queue.add(source(), "tester");
|
||||||
|
queue.startNext();
|
||||||
|
|
||||||
|
queue.clear();
|
||||||
|
|
||||||
|
expect(queue.snapshot()).toEqual({ current: null, queue: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
71
tests/media/mediaResolver.test.ts
Normal file
71
tests/media/mediaResolver.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AppError } from "../../src/errors";
|
||||||
|
import { resolveMediaSource } from "../../src/media/mediaResolver";
|
||||||
|
|
||||||
|
describe("resolveMediaSource", () => {
|
||||||
|
it("accepts http URLs", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveMediaSource("https://example.com/music.mp3"),
|
||||||
|
).resolves.toEqual({
|
||||||
|
source: "https://example.com/music.mp3",
|
||||||
|
title: "music.mp3",
|
||||||
|
kind: "url",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts existing local files", async () => {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "media-resolver-"));
|
||||||
|
const file = path.join(dir, "song.ogg");
|
||||||
|
writeFileSync(file, "audio");
|
||||||
|
|
||||||
|
await expect(resolveMediaSource(file)).resolves.toEqual({
|
||||||
|
source: file,
|
||||||
|
title: "song.ogg",
|
||||||
|
kind: "local",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty sources", async () => {
|
||||||
|
await expect(resolveMediaSource(" ")).rejects.toMatchObject({
|
||||||
|
code: "MISSING_MEDIA_SOURCE",
|
||||||
|
statusCode: 400,
|
||||||
|
} satisfies Partial<AppError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes URL titles", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveMediaSource("https://example.com/%2e%2e%2fsecret.mp3"),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
title: "secret.mp3",
|
||||||
|
kind: "url",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported sources", async () => {
|
||||||
|
await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject(
|
||||||
|
{
|
||||||
|
code: "UNSUPPORTED_MEDIA_SOURCE",
|
||||||
|
statusCode: 400,
|
||||||
|
} satisfies Partial<AppError>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-http URL sources", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveMediaSource("file:///tmp/song.mp3"),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "UNSUPPORTED_MEDIA_SOURCE",
|
||||||
|
statusCode: 400,
|
||||||
|
} satisfies Partial<AppError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed http URLs as unsupported sources", async () => {
|
||||||
|
await expect(resolveMediaSource("https://[invalid")).rejects.toMatchObject({
|
||||||
|
code: "UNSUPPORTED_MEDIA_SOURCE",
|
||||||
|
statusCode: 400,
|
||||||
|
} satisfies Partial<AppError>);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
tests/media/musicPlayer.test.ts
Normal file
104
tests/media/musicPlayer.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DiscordAudioPlayer } from "../../src/media/mediaTypes";
|
||||||
|
import { createMusicPlayer } from "../../src/media/musicPlayer";
|
||||||
|
|
||||||
|
class FakeProcess extends EventEmitter {
|
||||||
|
stdout = new PassThrough();
|
||||||
|
stderr = new PassThrough();
|
||||||
|
killed = false;
|
||||||
|
kill = vi.fn(() => {
|
||||||
|
this.killed = true;
|
||||||
|
this.emit("close", 0);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createMusicPlayer", () => {
|
||||||
|
it("spawns ffmpeg as Ogg Opus and passes stdout to Discord", async () => {
|
||||||
|
const proc = new FakeProcess();
|
||||||
|
const spawn = vi.fn(() => proc);
|
||||||
|
const discordPlayer: DiscordAudioPlayer = {
|
||||||
|
isConnected: () => true,
|
||||||
|
playStream: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
const player = createMusicPlayer({ spawn, discordPlayer });
|
||||||
|
|
||||||
|
const playback = player.play({
|
||||||
|
source: "https://example.com/song.mp3",
|
||||||
|
title: "song.mp3",
|
||||||
|
kind: "url",
|
||||||
|
});
|
||||||
|
proc.emit("close", 0);
|
||||||
|
await playback.done;
|
||||||
|
|
||||||
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
|
"ffmpeg",
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-i",
|
||||||
|
"https://example.com/song.mp3",
|
||||||
|
"-vn",
|
||||||
|
"-acodec",
|
||||||
|
"libopus",
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-f",
|
||||||
|
"ogg",
|
||||||
|
"pipe:1",
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects playback when Discord is not connected", () => {
|
||||||
|
const spawn = vi.fn(() => new FakeProcess());
|
||||||
|
const discordPlayer: DiscordAudioPlayer = {
|
||||||
|
isConnected: () => false,
|
||||||
|
playStream: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
const player = createMusicPlayer({ spawn, discordPlayer });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
player.play({
|
||||||
|
source: "/tmp/song.ogg",
|
||||||
|
title: "song.ogg",
|
||||||
|
kind: "local",
|
||||||
|
}),
|
||||||
|
).toThrow("Discord audio player is not connected");
|
||||||
|
expect(spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kills ffmpeg and stops Discord playback once", () => {
|
||||||
|
const proc = new FakeProcess();
|
||||||
|
const discordPlayer: DiscordAudioPlayer = {
|
||||||
|
isConnected: () => true,
|
||||||
|
playStream: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
const player = createMusicPlayer({
|
||||||
|
spawn: vi.fn(() => proc),
|
||||||
|
discordPlayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playback = player.play({
|
||||||
|
source: "/tmp/song.ogg",
|
||||||
|
title: "song.ogg",
|
||||||
|
kind: "local",
|
||||||
|
});
|
||||||
|
playback.stop();
|
||||||
|
playback.stop();
|
||||||
|
|
||||||
|
expect(proc.kill).toHaveBeenCalledTimes(1);
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
expect(discordPlayer.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests/moderation/messageCaptureFilter.test.ts
Normal file
27
tests/moderation/messageCaptureFilter.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldCaptureMessageLocation } from "../../src/moderation/messageCapture";
|
||||||
|
|
||||||
|
describe("shouldCaptureMessageLocation", () => {
|
||||||
|
it("matches only configured text guild and optional channel", () => {
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-1", channelId: "channel-2" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldCaptureMessageLocation(
|
||||||
|
{ guildId: "guild-2", channelId: "channel-1" },
|
||||||
|
{ guildId: "guild-1", channelId: "channel-1" },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
tests/routes/mediaRoutes.test.ts
Normal file
90
tests/routes/mediaRoutes.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createMediaRoutes } from "../../src/routes/mediaRoutes";
|
||||||
|
|
||||||
|
function getHandler(
|
||||||
|
router: ReturnType<typeof createMediaRoutes>,
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
) {
|
||||||
|
const layer = router.stack.find((item) => item.route?.path === path);
|
||||||
|
return layer?.route?.stack.find((item) => item.method === method)?.handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createMediaRoutes", () => {
|
||||||
|
it("returns media status", async () => {
|
||||||
|
const controller = {
|
||||||
|
getState: vi.fn(() => ({ playing: false, current: null, queue: [] })),
|
||||||
|
queue: vi.fn(),
|
||||||
|
skip: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
const handler = getHandler(
|
||||||
|
createMediaRoutes(controller),
|
||||||
|
"/media/status",
|
||||||
|
"get",
|
||||||
|
);
|
||||||
|
const json = vi.fn();
|
||||||
|
|
||||||
|
await handler?.({} as Request, { json } as unknown as Response, vi.fn());
|
||||||
|
|
||||||
|
expect(json).toHaveBeenCalledWith({
|
||||||
|
playing: false,
|
||||||
|
current: null,
|
||||||
|
queue: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues a source", async () => {
|
||||||
|
const state = { playing: true, current: null, queue: [] };
|
||||||
|
const controller = {
|
||||||
|
getState: vi.fn(),
|
||||||
|
queue: vi.fn(async () => state),
|
||||||
|
skip: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
const handler = getHandler(
|
||||||
|
createMediaRoutes(controller),
|
||||||
|
"/media/queue",
|
||||||
|
"post",
|
||||||
|
);
|
||||||
|
const json = vi.fn();
|
||||||
|
|
||||||
|
await handler?.(
|
||||||
|
{ body: { source: "https://example.com/song.mp3" } } as Request,
|
||||||
|
{ json } as unknown as Response,
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller.queue).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/song.mp3",
|
||||||
|
);
|
||||||
|
expect(json).toHaveBeenCalledWith(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes missing source errors to Express", async () => {
|
||||||
|
const controller = {
|
||||||
|
getState: vi.fn(),
|
||||||
|
queue: vi.fn(),
|
||||||
|
skip: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
const handler = getHandler(
|
||||||
|
createMediaRoutes(controller),
|
||||||
|
"/media/queue",
|
||||||
|
"post",
|
||||||
|
);
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await handler?.(
|
||||||
|
{ body: {} } as Request,
|
||||||
|
{ json: vi.fn() } as unknown as Response,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next.mock.calls[0][0]).toMatchObject({
|
||||||
|
code: "MISSING_MEDIA_SOURCE",
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
97
tests/routes/syncRoutes.test.ts
Normal file
97
tests/routes/syncRoutes.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
clearRecentBacklogSyncs,
|
||||||
|
createSyncRoutes,
|
||||||
|
shouldSkipRecentBacklogSync,
|
||||||
|
} from "../../src/routes/syncRoutes";
|
||||||
|
|
||||||
|
const syncSelectedChannelBacklog = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../src/moderation/backlogSync", () => ({
|
||||||
|
syncSelectedChannelBacklog,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("createSyncRoutes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
syncSelectedChannelBacklog.mockReset();
|
||||||
|
clearRecentBacklogSyncs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs the selected guild and channel from the request", async () => {
|
||||||
|
syncSelectedChannelBacklog.mockResolvedValue(3);
|
||||||
|
const router = createSyncRoutes({} as never);
|
||||||
|
const route = router.stack.find(
|
||||||
|
(layer) => layer.route?.path === "/backlog-sync",
|
||||||
|
);
|
||||||
|
const handler = route?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
const json = vi.fn();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await handler?.(
|
||||||
|
{
|
||||||
|
body: { guildId: "selected-guild", channelId: "selected-channel" },
|
||||||
|
} as Request,
|
||||||
|
{ json } as unknown as Response,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(syncSelectedChannelBacklog).toHaveBeenCalledWith(
|
||||||
|
{},
|
||||||
|
"selected-guild",
|
||||||
|
"selected-channel",
|
||||||
|
);
|
||||||
|
expect(json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
channelId: "selected-channel",
|
||||||
|
messagesSync: 3,
|
||||||
|
skipped: false,
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips repeated sync requests during the cooldown window", async () => {
|
||||||
|
expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false);
|
||||||
|
expect(shouldSkipRecentBacklogSync("guild", "channel", 1001)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows repeated sync requests after the cooldown window", async () => {
|
||||||
|
expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false);
|
||||||
|
expect(shouldSkipRecentBacklogSync("guild", "channel", 301001)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call Discord backlog sync for repeated requests", async () => {
|
||||||
|
syncSelectedChannelBacklog.mockResolvedValue(3);
|
||||||
|
const router = createSyncRoutes({} as never);
|
||||||
|
const route = router.stack.find(
|
||||||
|
(layer) => layer.route?.path === "/backlog-sync",
|
||||||
|
);
|
||||||
|
const handler = route?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
await handler?.(
|
||||||
|
{
|
||||||
|
body: { guildId: "selected-guild", channelId: "selected-channel" },
|
||||||
|
} as Request,
|
||||||
|
{ json: vi.fn() } as unknown as Response,
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = vi.fn();
|
||||||
|
await handler?.(
|
||||||
|
{
|
||||||
|
body: { guildId: "selected-guild", channelId: "selected-channel" },
|
||||||
|
} as Request,
|
||||||
|
{ json } as unknown as Response,
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(syncSelectedChannelBacklog).toHaveBeenCalledTimes(1);
|
||||||
|
expect(json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
channelId: "selected-channel",
|
||||||
|
messagesSync: 0,
|
||||||
|
skipped: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,8 @@ describe("Discord video stream workspace dependencies", () => {
|
|||||||
expect(videoStreamPackage.devDependencies?.["discord.js-selfbot-v13"]).toBe(
|
expect(videoStreamPackage.devDependencies?.["discord.js-selfbot-v13"]).toBe(
|
||||||
"workspace:*",
|
"workspace:*",
|
||||||
);
|
);
|
||||||
expect(videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"]).toBe(
|
expect(
|
||||||
"^3.6.0",
|
videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"],
|
||||||
);
|
).toBe("^3.6.0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user