mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: persist volume, play mode and audio quality across restarts
Runtime playback settings were kept only in memory (AudioPlayer.volume, PlayQueue.mode, each provider's quality field), so restarting the bot reset them to defaults and users had to re-tune volume and quality every time (#125). Persist and restore them via the repo's existing storage: - Volume and play mode are per-bot, stored on new bot_instances columns (volume, play_mode) with a schema migration; restored when the instance is (re)built, written by cmdVol / cmdMode which every entry point (chat command, WebUI, REST) funnels through. Volume and mode are written independently so a transient !fm/!artist mode switch never overwrites the user's saved !mode. - Per-provider audio quality is global (shared providers), stored in a new config.json `audioQuality` block; applied to the providers at startup and re-snapshotted on POST /api/music/quality. Queue, current song, progress and FM/artist sessions stay ephemeral. Adds tests for config sanitize/round-trip, DB player-settings + migration, cmdVol/cmdMode persistence + construction-time restore, and quality persistence through the REST endpoint. Documents the behavior in the README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
051171b019
commit
fbd94c424b
11 files changed
+618
-12
No files matched your search
@@ -6,6 +6,7 @@ import type { SpotifyController } from "../music/spotify/controller.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import { createDatabase } from "../data/database.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
|
||||
@@ -832,6 +833,9 @@ describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1
|
||||
const database = {
|
||||
getProfileConfig: () => ({}),
|
||||
getCustomAvatarPath: () => null,
|
||||
getPlayerSettings: () => ({ volume: 75, playMode: "seq" }),
|
||||
saveVolume: () => {},
|
||||
savePlayMode: () => {},
|
||||
} as unknown as BotDatabase;
|
||||
const options: BotInstanceOptions = {
|
||||
id: "bot-oauth-test",
|
||||
@@ -912,6 +916,136 @@ describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Persisting volume + play mode across restarts (#125) ------------------
|
||||
const cmdVol = (BotInstance.prototype as any).cmdVol as (this: unknown, cmd: any) => string;
|
||||
const cmdMode = (BotInstance.prototype as any).cmdMode as (this: unknown, cmd: any) => string;
|
||||
|
||||
describe("BotInstance.cmdVol — persistence (#125)", () => {
|
||||
function makeVolCtx() {
|
||||
let stored = 75;
|
||||
return {
|
||||
id: "bot1",
|
||||
player: {
|
||||
setVolume: vi.fn((v: number) => { stored = v; }),
|
||||
getVolume: vi.fn(() => stored),
|
||||
},
|
||||
database: { saveVolume: vi.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
emit: vi.fn(),
|
||||
// The real private persist helper lives on the prototype; wire it so the
|
||||
// test exercises the shipped persistence path end-to-end.
|
||||
persistVolume: (BotInstance.prototype as any).persistVolume,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("saves the new volume via database.saveVolume (covers chat !vol AND the REST endpoint)", () => {
|
||||
const ctx = makeVolCtx();
|
||||
const res = cmdVol.call(ctx, { args: "40" });
|
||||
expect(res).toBe("Volume set to 40%");
|
||||
expect(ctx.player.setVolume).toHaveBeenCalledWith(40);
|
||||
expect(ctx.database.saveVolume).toHaveBeenCalledWith("bot1", 40);
|
||||
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
|
||||
});
|
||||
|
||||
it("does not persist an out-of-range volume", () => {
|
||||
const ctx = makeVolCtx();
|
||||
const res = cmdVol.call(ctx, { args: "999" });
|
||||
expect(res).toBe("Usage: !vol <0-100>");
|
||||
expect(ctx.player.setVolume).not.toHaveBeenCalled();
|
||||
expect(ctx.database.saveVolume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows a database error so the volume change still succeeds", () => {
|
||||
const ctx = makeVolCtx();
|
||||
ctx.database.saveVolume = vi.fn(() => { throw new Error("disk full"); });
|
||||
const res = cmdVol.call(ctx, { args: "50" });
|
||||
expect(res).toBe("Volume set to 50%");
|
||||
expect(ctx.player.setVolume).toHaveBeenCalledWith(50);
|
||||
expect(ctx.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotInstance.cmdMode — persistence (#125)", () => {
|
||||
function makeModeCtx() {
|
||||
let mode = "seq";
|
||||
return {
|
||||
id: "bot1",
|
||||
queue: {
|
||||
setMode: vi.fn((m: string) => { mode = m; }),
|
||||
getMode: vi.fn(() => mode),
|
||||
},
|
||||
database: { savePlayMode: vi.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
emit: vi.fn(),
|
||||
persistPlayMode: (BotInstance.prototype as any).persistPlayMode,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("saves the new play mode via database.savePlayMode", () => {
|
||||
const ctx = makeModeCtx();
|
||||
const res = cmdMode.call(ctx, { args: "rloop" });
|
||||
expect(res).toBe("Play mode set to: rloop");
|
||||
expect(ctx.queue.setMode).toHaveBeenCalledWith("rloop");
|
||||
expect(ctx.database.savePlayMode).toHaveBeenCalledWith("bot1", "rloop");
|
||||
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
|
||||
});
|
||||
|
||||
it("does not persist an unknown mode", () => {
|
||||
const ctx = makeModeCtx();
|
||||
const res = cmdMode.call(ctx, { args: "bogus" });
|
||||
expect(res).toBe("Usage: !mode <seq|loop|random|rloop>");
|
||||
expect(ctx.queue.setMode).not.toHaveBeenCalled();
|
||||
expect(ctx.database.savePlayMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotInstance — restores persisted player settings on construction (#125)", () => {
|
||||
const provider = { platform: "netease" } as unknown as MusicProvider;
|
||||
function makeOptions(id: string, database: BotDatabase): BotInstanceOptions {
|
||||
const logger: any = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } };
|
||||
return {
|
||||
id,
|
||||
name: "RestoreBot",
|
||||
tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "RestoreBot" } as any,
|
||||
neteaseProvider: provider,
|
||||
qqProvider: provider,
|
||||
bilibiliProvider: provider,
|
||||
youtubeProvider: provider,
|
||||
database,
|
||||
config: { spotify: {} } as unknown as BotConfig,
|
||||
logger,
|
||||
avatarStore: { read: () => null } as unknown as AvatarStore,
|
||||
spotifyControllerFactory: () => ({ on: () => {} } as unknown as SpotifyController),
|
||||
};
|
||||
}
|
||||
|
||||
it("applies the saved volume + play mode from the database", () => {
|
||||
const db = createDatabase(":memory:");
|
||||
db.saveBotInstance({
|
||||
id: "bot-restore", name: "B", serverAddress: "x", serverPort: 9987, nickname: "n",
|
||||
defaultChannel: "", channelId: "", channelPassword: "", autoStart: false,
|
||||
serverProtocol: "", ts6ApiKey: "", serverPassword: "",
|
||||
});
|
||||
db.saveVolume("bot-restore", 33);
|
||||
db.savePlayMode("bot-restore", "loop");
|
||||
|
||||
const bot = new BotInstance(makeOptions("bot-restore", db));
|
||||
const status = bot.getStatus();
|
||||
expect(status.volume).toBe(33);
|
||||
expect(status.playMode).toBe("loop");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("falls back to defaults for a bot with no saved settings", () => {
|
||||
const db = createDatabase(":memory:");
|
||||
const bot = new BotInstance(makeOptions("brand-new", db));
|
||||
const status = bot.getStatus();
|
||||
expect(status.volume).toBe(75);
|
||||
expect(status.playMode).toBe("seq");
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
|
||||
it("splits a long command response into multiple sends, each under the byte cap", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [] });
|
||||
|
||||
+52
-7
@@ -39,6 +39,15 @@ import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||
|
||||
/** Maps the persisted / command-line play-mode string to the PlayMode enum.
|
||||
* Shared by the !mode command and the restart-restore path (#125). */
|
||||
const PLAY_MODE_BY_VALUE: Record<string, PlayMode> = {
|
||||
seq: PlayMode.Sequential,
|
||||
loop: PlayMode.Loop,
|
||||
random: PlayMode.Random,
|
||||
rloop: PlayMode.RandomLoop,
|
||||
};
|
||||
|
||||
/** Fallback message when Spotify audio can't be served (backend unavailable
|
||||
* OR a per-track playTrack failure against a dead/failed sidecar). */
|
||||
const SPOTIFY_UNAVAILABLE_MESSAGE =
|
||||
@@ -188,6 +197,19 @@ export class BotInstance extends EventEmitter {
|
||||
this.player = new AudioPlayer(this.logger);
|
||||
this.queue = new PlayQueue();
|
||||
|
||||
// Restore persisted per-bot player settings (#125): volume + play mode
|
||||
// survive restarts. getPlayerSettings returns validated values (the in-memory
|
||||
// defaults when the row/column is absent), so this is a harmless no-op for a
|
||||
// brand-new bot and reproduces the saved state for an existing one.
|
||||
try {
|
||||
const settings = this.database.getPlayerSettings(this.id);
|
||||
this.player.setVolume(settings.volume);
|
||||
const restoredMode = PLAY_MODE_BY_VALUE[settings.playMode];
|
||||
if (restoredMode) this.queue.setMode(restoredMode);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, "Failed to restore player settings — using defaults");
|
||||
}
|
||||
|
||||
// Structural typing (like localProvider.sweepUnreferenced): only the real
|
||||
// JellyfinProvider exposes createPlaybackReporter, so the netease fallback
|
||||
// provider simply leaves reporting off.
|
||||
@@ -1136,10 +1158,36 @@ export class BotInstance extends EventEmitter {
|
||||
const vol = parseInt(cmd.args, 10);
|
||||
if (isNaN(vol) || vol < 0 || vol > 100) return "Usage: !vol <0-100>";
|
||||
this.player.setVolume(vol);
|
||||
// Persist so the volume survives a restart (#125). Both the chat !vol command
|
||||
// and the WebUI/REST volume endpoint funnel through here, so one write covers
|
||||
// every entry point. Only volume is written — play mode is saved independently.
|
||||
this.persistVolume();
|
||||
this.emit("stateChange");
|
||||
return `Volume set to ${vol}%`;
|
||||
}
|
||||
|
||||
/** Persist the current volume (#125). Best-effort: a DB error must never break
|
||||
* the volume change itself. */
|
||||
private persistVolume(): void {
|
||||
try {
|
||||
this.database.saveVolume(this.id, this.player.getVolume());
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, "Failed to persist volume");
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the current play mode (#125). Best-effort, mirrors persistVolume.
|
||||
* Called ONLY from the explicit !mode command — NOT from FM/artist mode, whose
|
||||
* Random/Loop switch is a transient side effect that must not overwrite the
|
||||
* user's saved preference. */
|
||||
private persistPlayMode(): void {
|
||||
try {
|
||||
this.database.savePlayMode(this.id, this.queue.getMode());
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, "Failed to persist play mode");
|
||||
}
|
||||
}
|
||||
|
||||
private cmdNow(): string {
|
||||
const song = this.queue.current();
|
||||
if (!song) return "Nothing is playing";
|
||||
@@ -1205,15 +1253,12 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
private cmdMode(cmd: ParsedCommand): string {
|
||||
const modeMap: Record<string, PlayMode> = {
|
||||
seq: PlayMode.Sequential,
|
||||
loop: PlayMode.Loop,
|
||||
random: PlayMode.Random,
|
||||
rloop: PlayMode.RandomLoop,
|
||||
};
|
||||
const mode = modeMap[cmd.args];
|
||||
const mode = PLAY_MODE_BY_VALUE[cmd.args];
|
||||
if (mode === undefined) return "Usage: !mode <seq|loop|random|rloop>";
|
||||
this.queue.setMode(mode);
|
||||
// Persist so the play mode survives a restart (#125). The chat !mode command
|
||||
// and the WebUI/REST mode endpoint both funnel through here.
|
||||
this.persistPlayMode();
|
||||
this.emit("stateChange");
|
||||
return `Play mode set to: ${cmd.args}`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user