mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #130 from ZHANGTIANYAO1/feat/issue-125-persist-settings
feat: persist volume, play mode and audio quality across restarts
This commit is contained in:
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}`;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,60 @@ describe("config", () => {
|
||||
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||
});
|
||||
|
||||
// ── audioQuality persistence (#125) ─────────────────────────────────────
|
||||
it("defaults audioQuality to each provider's in-memory default", () => {
|
||||
const config = getDefaultConfig();
|
||||
expect(config.audioQuality).toEqual({
|
||||
netease: "exhigh",
|
||||
qq: "exhigh",
|
||||
bilibili: "high",
|
||||
kugou: "128",
|
||||
jellyfin: "direct",
|
||||
});
|
||||
});
|
||||
|
||||
it("fills audioQuality defaults for a legacy config without the field", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
|
||||
const config = loadConfig(path);
|
||||
expect(config.audioQuality).toEqual(getDefaultConfig().audioQuality);
|
||||
});
|
||||
|
||||
it("round-trips a saved audioQuality through save/load", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
const config = getDefaultConfig();
|
||||
config.audioQuality = {
|
||||
netease: "lossless",
|
||||
qq: "flac",
|
||||
bilibili: "high",
|
||||
kugou: "flac",
|
||||
jellyfin: "320",
|
||||
};
|
||||
saveConfig(path, config);
|
||||
const loaded = loadConfig(path);
|
||||
expect(loaded.audioQuality).toEqual(config.audioQuality);
|
||||
});
|
||||
|
||||
it("coerces missing / non-string audioQuality fields to defaults", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
// netease valid, qq blank, bilibili wrong type, kugou missing, jellyfin valid.
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ audioQuality: { netease: "lossless", qq: " ", bilibili: 320, jellyfin: "192" } }),
|
||||
);
|
||||
const config = loadConfig(path);
|
||||
expect(config.audioQuality).toEqual({
|
||||
netease: "lossless",
|
||||
qq: "exhigh", // blank → default
|
||||
bilibili: "high", // non-string → default
|
||||
kugou: "128", // missing → default
|
||||
jellyfin: "192",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates config file on save", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "sub", "config.json");
|
||||
|
||||
@@ -38,6 +38,22 @@ export interface JellyfinConfig {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider audio quality (音质), persisted so a restart keeps the user's
|
||||
* choice instead of resetting each provider to its in-memory default (#125).
|
||||
* The values are the same strings the WebUI/REST `POST /api/music/quality`
|
||||
* endpoint sends and each provider's setQuality() accepts; on startup they are
|
||||
* replayed onto the (shared, process-wide) providers. Providers ignore/normalize
|
||||
* unknown values, so a stale/hand-edited entry can never break playback.
|
||||
*/
|
||||
export interface AudioQualityConfig {
|
||||
netease: string;
|
||||
qq: string;
|
||||
bilibili: string;
|
||||
kugou: string;
|
||||
jellyfin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers gated by `enabledProviders`. Not listed here:
|
||||
* - "local" → governed by the existing `localAudioEnabled` flag
|
||||
@@ -102,6 +118,8 @@ export interface BotConfig {
|
||||
guestMode: GuestModeConfig;
|
||||
spotify: SpotifyConfig;
|
||||
jellyfin: JellyfinConfig;
|
||||
/** Persisted per-provider audio quality (音质), restored on startup (#125). */
|
||||
audioQuality: AudioQualityConfig;
|
||||
/**
|
||||
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
|
||||
* the online sources (NetEase/QQ/Bilibili/YouTube/Kugou); jellyfin is an
|
||||
@@ -162,6 +180,15 @@ export function getDefaultConfig(): BotConfig {
|
||||
apiKey: "",
|
||||
userId: "",
|
||||
},
|
||||
// Mirrors each provider's own in-memory default quality; overwritten on
|
||||
// startup once the user has changed a quality (persisted via #125).
|
||||
audioQuality: {
|
||||
netease: "exhigh",
|
||||
qq: "exhigh",
|
||||
bilibili: "high",
|
||||
kugou: "128",
|
||||
jellyfin: "direct",
|
||||
},
|
||||
enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"],
|
||||
};
|
||||
}
|
||||
@@ -317,6 +344,20 @@ export function loadConfig(path: string): BotConfig {
|
||||
)
|
||||
: defaults.enabledProviders;
|
||||
|
||||
// audioQuality → per-provider strings; each field falls back to its default
|
||||
// when missing/blank/non-string (a hand-edited/legacy config must never smuggle
|
||||
// a non-string past the gate — the value is fed straight to provider.setQuality).
|
||||
const partialAq = (partial.audioQuality ?? {}) as Partial<AudioQualityConfig>;
|
||||
const coerceQuality = (v: unknown, fallback: string): string =>
|
||||
typeof v === "string" && v.trim() ? v : fallback;
|
||||
const audioQuality: AudioQualityConfig = {
|
||||
netease: coerceQuality(partialAq.netease, defaults.audioQuality.netease),
|
||||
qq: coerceQuality(partialAq.qq, defaults.audioQuality.qq),
|
||||
bilibili: coerceQuality(partialAq.bilibili, defaults.audioQuality.bilibili),
|
||||
kugou: coerceQuality(partialAq.kugou, defaults.audioQuality.kugou),
|
||||
jellyfin: coerceQuality(partialAq.jellyfin, defaults.audioQuality.jellyfin),
|
||||
};
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...partial,
|
||||
@@ -324,6 +365,7 @@ export function loadConfig(path: string): BotConfig {
|
||||
guestMode: gm,
|
||||
spotify,
|
||||
jellyfin,
|
||||
audioQuality,
|
||||
enabledProviders,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +131,92 @@ describe("database", () => {
|
||||
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
|
||||
});
|
||||
|
||||
it("persists and restores per-bot player settings (volume + play mode) (#125)", () => {
|
||||
const inst = {
|
||||
id: "bot-ps",
|
||||
name: "B",
|
||||
serverAddress: "x",
|
||||
serverPort: 9987,
|
||||
nickname: "n",
|
||||
defaultChannel: "",
|
||||
channelId: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
};
|
||||
botDb.saveBotInstance(inst);
|
||||
|
||||
// Fresh row → in-memory defaults.
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 75, playMode: "seq" });
|
||||
|
||||
// Volume and play mode persist independently.
|
||||
botDb.saveVolume("bot-ps", 42);
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "seq" });
|
||||
botDb.savePlayMode("bot-ps", "rloop");
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
|
||||
|
||||
// A later saveBotInstance upsert (e.g. autoStart toggle) must NOT reset them.
|
||||
botDb.saveBotInstance({ ...inst, autoStart: true });
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
|
||||
});
|
||||
|
||||
it("defaults player settings for an unknown bot and validates inputs (#125)", () => {
|
||||
// No row → defaults.
|
||||
expect(botDb.getPlayerSettings("does-not-exist")).toEqual({ volume: 75, playMode: "seq" });
|
||||
|
||||
botDb.saveBotInstance({
|
||||
id: "bot-v",
|
||||
name: "B",
|
||||
serverAddress: "x",
|
||||
serverPort: 9987,
|
||||
nickname: "n",
|
||||
defaultChannel: "",
|
||||
channelId: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
});
|
||||
// Out-of-range volume is clamped; an unknown play mode is ignored (not stored).
|
||||
botDb.saveVolume("bot-v", 250);
|
||||
expect(botDb.getPlayerSettings("bot-v").volume).toBe(100);
|
||||
botDb.saveVolume("bot-v", -10);
|
||||
expect(botDb.getPlayerSettings("bot-v").volume).toBe(0);
|
||||
botDb.savePlayMode("bot-v", "bogus");
|
||||
expect(botDb.getPlayerSettings("bot-v").playMode).toBe("seq");
|
||||
});
|
||||
|
||||
it("migrates volume + play_mode columns onto a legacy bot_instances table (#125)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-mig-"));
|
||||
const p = join(dir, "legacy.db");
|
||||
// Build a minimal pre-#125 bot_instances table (no volume/play_mode columns).
|
||||
const legacy = createDatabase(p);
|
||||
legacy.db.exec("DROP TABLE bot_instances");
|
||||
legacy.db.exec(`CREATE TABLE bot_instances (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, serverAddress TEXT NOT NULL,
|
||||
serverPort INTEGER NOT NULL, nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL,
|
||||
channelId TEXT NOT NULL DEFAULT '', channelPassword TEXT NOT NULL,
|
||||
autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '',
|
||||
ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', identity TEXT
|
||||
)`);
|
||||
legacy.db
|
||||
.prepare("INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword) VALUES (?, 'B', 'x', 9987, 'n', '', '')")
|
||||
.run("legacy-bot");
|
||||
legacy.close();
|
||||
|
||||
// Reopen → migrateSchema adds the columns; the old row gets the defaults.
|
||||
const reopened = createDatabase(p);
|
||||
const cols = (reopened.db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>).map((c) => c.name);
|
||||
expect(cols).toContain("volume");
|
||||
expect(cols).toContain("play_mode");
|
||||
expect(reopened.getPlayerSettings("legacy-bot")).toEqual({ volume: 75, playMode: "seq" });
|
||||
reopened.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists and clears customAvatarPath on a bot instance", () => {
|
||||
const inst = {
|
||||
id: "bot-1",
|
||||
|
||||
@@ -55,6 +55,26 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
||||
nowPlayingMsgEnabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-bot player settings persisted across restarts (#125): the playback volume
|
||||
* and play mode. These reset to defaults on process restart when kept only in
|
||||
* memory (AudioPlayer/PlayQueue), so they are stored on the bot_instances row —
|
||||
* exactly like the per-bot profile flags — and restored when the bot is (re)built.
|
||||
*/
|
||||
export interface PlayerSettings {
|
||||
/** 0-100. */
|
||||
volume: number;
|
||||
/** PlayMode string: "seq" | "loop" | "random" | "rloop". */
|
||||
playMode: string;
|
||||
}
|
||||
|
||||
const PLAY_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
||||
|
||||
export const DEFAULT_PLAYER_SETTINGS: PlayerSettings = {
|
||||
volume: 75,
|
||||
playMode: "seq",
|
||||
};
|
||||
|
||||
export interface FavoritePlaylist {
|
||||
id: number;
|
||||
userId: string;
|
||||
@@ -75,6 +95,9 @@ export interface BotDatabase {
|
||||
deleteBotInstance(id: string): boolean;
|
||||
getProfileConfig(botId: string): ProfileConfig;
|
||||
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||
getPlayerSettings(botId: string): PlayerSettings;
|
||||
saveVolume(botId: string, volume: number): void;
|
||||
savePlayMode(botId: string, playMode: string): void;
|
||||
getCustomAvatarPath(botId: string): string | null;
|
||||
setCustomAvatarPath(botId: string, path: string | null): void;
|
||||
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
|
||||
@@ -119,6 +142,15 @@ function migrateSchema(db: Database.Database): void {
|
||||
if (!names.includes("custom_avatar_path")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
|
||||
}
|
||||
// Per-bot persisted player settings (#125): volume + play mode. Defaults match
|
||||
// AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving
|
||||
// exactly as before until the user changes them.
|
||||
if (!names.includes("volume")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75");
|
||||
}
|
||||
if (!names.includes("play_mode")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'");
|
||||
}
|
||||
|
||||
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
|
||||
const userColNames = userColumns.map((c) => c.name);
|
||||
@@ -161,6 +193,8 @@ function initTables(db: Database.Database): void {
|
||||
serverProtocol TEXT NOT NULL DEFAULT '',
|
||||
ts6ApiKey TEXT NOT NULL DEFAULT '',
|
||||
serverPassword TEXT NOT NULL DEFAULT '',
|
||||
volume INTEGER NOT NULL DEFAULT 75,
|
||||
play_mode TEXT NOT NULL DEFAULT 'seq',
|
||||
identity TEXT
|
||||
);
|
||||
|
||||
@@ -321,6 +355,12 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
const selectPlayerSettings = db.prepare(
|
||||
`SELECT volume, play_mode FROM bot_instances WHERE id = ?`,
|
||||
);
|
||||
const updateVolume = db.prepare(`UPDATE bot_instances SET volume = ? WHERE id = ?`);
|
||||
const updatePlayMode = db.prepare(`UPDATE bot_instances SET play_mode = ? WHERE id = ?`);
|
||||
|
||||
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
|
||||
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
|
||||
|
||||
@@ -406,6 +446,36 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
});
|
||||
},
|
||||
|
||||
getPlayerSettings(botId) {
|
||||
const row = selectPlayerSettings.get(botId) as
|
||||
| { volume: number | null; play_mode: string | null }
|
||||
| undefined;
|
||||
if (!row) return { ...DEFAULT_PLAYER_SETTINGS };
|
||||
// Coerce/validate: clamp volume to 0-100 and fall back to defaults for any
|
||||
// NULL / out-of-range / unknown value (a hand-edited DB must never feed a
|
||||
// bad value into AudioPlayer.setVolume / PlayQueue.setMode).
|
||||
const rawVol = typeof row.volume === "number" ? row.volume : DEFAULT_PLAYER_SETTINGS.volume;
|
||||
const volume = Number.isFinite(rawVol)
|
||||
? Math.max(0, Math.min(100, Math.round(rawVol)))
|
||||
: DEFAULT_PLAYER_SETTINGS.volume;
|
||||
const playMode =
|
||||
typeof row.play_mode === "string" && PLAY_MODES.has(row.play_mode)
|
||||
? row.play_mode
|
||||
: DEFAULT_PLAYER_SETTINGS.playMode;
|
||||
return { volume, playMode };
|
||||
},
|
||||
|
||||
saveVolume(botId, volume) {
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(volume)));
|
||||
updateVolume.run(clamped, botId);
|
||||
},
|
||||
|
||||
savePlayMode(botId, playMode) {
|
||||
// Persist only recognized modes so a bad value can never poison the row.
|
||||
if (!PLAY_MODES.has(playMode)) return;
|
||||
updatePlayMode.run(playMode, botId);
|
||||
},
|
||||
|
||||
getCustomAvatarPath(botId) {
|
||||
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
|
||||
return row?.custom_avatar_path ?? null;
|
||||
|
||||
@@ -100,6 +100,15 @@ async function main() {
|
||||
if (jellyfinAuth) jellyfinProvider.setCookie(jellyfinAuth);
|
||||
jellyfinProvider.setPersist((serialized) => cookieStore.save("jellyfin", serialized));
|
||||
|
||||
// Restore the persisted per-provider audio quality (#125) onto the shared,
|
||||
// process-wide providers so a restart keeps the user's choice. setQuality()
|
||||
// normalizes/ignores unknown values, so a stale entry can never break playback.
|
||||
neteaseProvider.setQuality(config.audioQuality.netease);
|
||||
qqProvider.setQuality(config.audioQuality.qq);
|
||||
bilibiliProvider.setQuality(config.audioQuality.bilibili);
|
||||
kugouProvider.setQuality(config.audioQuality.kugou);
|
||||
jellyfinProvider.setQuality(config.audioQuality.jellyfin);
|
||||
|
||||
const permissions = createPermissionStore(db.db);
|
||||
|
||||
// Single process-wide Spotify authorization (one Premium account for Stage 3).
|
||||
|
||||
+131
-2
@@ -1,9 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { MusicProvider, SearchResult } from "../../music/provider.js";
|
||||
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||
import { getDefaultConfig, loadConfig, type BotConfig } from "../../data/config.js";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
import { createMusicRouter } from "./music.js";
|
||||
|
||||
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||
@@ -147,3 +157,122 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("music router POST /quality — persistence (#125)", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let config: BotConfig;
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let cookie: string;
|
||||
let providers: Record<string, MusicProvider>;
|
||||
|
||||
/** A provider whose in-memory quality is settable and readable, like the real
|
||||
* ones. */
|
||||
function qualityProvider(platform: MusicProvider["platform"], initial: string): MusicProvider {
|
||||
let q = initial;
|
||||
return {
|
||||
platform,
|
||||
search: vi.fn().mockResolvedValue(empty),
|
||||
getQuality: vi.fn(() => q),
|
||||
setQuality: vi.fn((v: string) => { q = v; }),
|
||||
} as unknown as MusicProvider;
|
||||
}
|
||||
|
||||
/** Jellyfin only accepts its own tiers (mirrors the real provider), so a
|
||||
* broadcast of a foreign value is ignored — proving the snapshot captures each
|
||||
* provider's ACTUAL post-apply state, not just the request value. */
|
||||
function jellyfinQualityProvider(): MusicProvider {
|
||||
let q = "direct";
|
||||
const tiers = new Set(["direct", "320", "192", "128"]);
|
||||
return {
|
||||
platform: "jellyfin",
|
||||
search: vi.fn().mockResolvedValue(empty),
|
||||
getQuality: vi.fn(() => q),
|
||||
setQuality: vi.fn((v: string) => { if (tiers.has(v)) q = v; }),
|
||||
} as unknown as MusicProvider;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const admin = await users.createUser("admin", "pw-admin", "admin");
|
||||
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
|
||||
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "musicquality-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
config = getDefaultConfig();
|
||||
|
||||
providers = {
|
||||
netease: qualityProvider("netease", "exhigh"),
|
||||
qq: qualityProvider("qq", "exhigh"),
|
||||
bilibili: qualityProvider("bilibili", "high"),
|
||||
kugou: qualityProvider("kugou", "128"),
|
||||
jellyfin: jellyfinQualityProvider(),
|
||||
};
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(
|
||||
providers.netease, providers.qq, providers.bilibili, pino({ level: "silent" }),
|
||||
undefined, config, providers.kugou, undefined, providers.jellyfin, configPath,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists a platform-specific quality change to config.json", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/quality")
|
||||
.set("Cookie", cookie)
|
||||
.send({ platform: "netease", quality: "lossless" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(providers.netease.setQuality).toHaveBeenCalledWith("lossless");
|
||||
// in-memory config mutated
|
||||
expect(config.audioQuality.netease).toBe("lossless");
|
||||
// written to disk + reload reflects it (survives a restart)
|
||||
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
expect(onDisk.audioQuality.netease).toBe("lossless");
|
||||
expect(loadConfig(configPath).audioQuality.netease).toBe("lossless");
|
||||
});
|
||||
|
||||
it("snapshots each provider's post-apply quality on a broadcast change", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/quality")
|
||||
.set("Cookie", cookie)
|
||||
.send({ quality: "320" });
|
||||
expect(res.status).toBe(200);
|
||||
// Broadcast reached every provider…
|
||||
expect(providers.netease.setQuality).toHaveBeenCalledWith("320");
|
||||
expect(providers.jellyfin.setQuality).toHaveBeenCalledWith("320");
|
||||
// …and the snapshot reflects what each one actually accepted. Jellyfin's
|
||||
// "320" is a valid tier here, so it takes; a foreign value would be ignored.
|
||||
expect(config.audioQuality).toEqual({
|
||||
netease: "320",
|
||||
qq: "320",
|
||||
bilibili: "320",
|
||||
kugou: "320",
|
||||
jellyfin: "320",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores foreign broadcast values that a provider rejects (jellyfin)", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/quality")
|
||||
.set("Cookie", cookie)
|
||||
.send({ quality: "lossless" });
|
||||
expect(res.status).toBe(200);
|
||||
// jellyfin rejects the NetEase-style value → stays at its default tier.
|
||||
expect(config.audioQuality.jellyfin).toBe("direct");
|
||||
expect(config.audioQuality.netease).toBe("lossless");
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -2,7 +2,7 @@ import express, { Router, type Response } from "express";
|
||||
import type { MusicProvider, Song, Album } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { isProviderEnabled, defaultPlatform, type BotConfig } from "../../data/config.js";
|
||||
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
@@ -16,7 +16,10 @@ export function createMusicRouter(
|
||||
config?: BotConfig,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider,
|
||||
jellyfinProvider?: MusicProvider
|
||||
jellyfinProvider?: MusicProvider,
|
||||
// When set (alongside config), a quality change is persisted to config.json so
|
||||
// it survives a restart (#125). Omitted by unit-test routers → no persistence.
|
||||
configPath?: string,
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
@@ -480,6 +483,26 @@ export function createMusicRouter(
|
||||
if ((!platform || platform === "jellyfin") && jellyfinProvider) {
|
||||
jellyfinProvider.setQuality(quality);
|
||||
}
|
||||
|
||||
// Persist the (post-apply) per-provider quality so it survives a restart
|
||||
// (#125). Snapshotting each provider's getQuality() AFTER setQuality captures
|
||||
// exactly what each one accepted (jellyfin ignores foreign tiers, kugou maps
|
||||
// aliases), so replaying these on startup reproduces this state faithfully.
|
||||
if (config && configPath) {
|
||||
config.audioQuality = {
|
||||
netease: neteaseProvider.getQuality(),
|
||||
qq: qqProvider.getQuality(),
|
||||
bilibili: bilibiliProvider.getQuality(),
|
||||
kugou: kugouProvider?.getQuality() ?? config.audioQuality.kugou,
|
||||
jellyfin: jellyfinProvider?.getQuality() ?? config.audioQuality.jellyfin,
|
||||
};
|
||||
try {
|
||||
saveConfig(configPath, config);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to persist audio quality");
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ quality, platform }, "Audio quality changed");
|
||||
res.json({ success: true, quality });
|
||||
});
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider)
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.configPath)
|
||||
);
|
||||
app.use("/api/player", createPlayerRouter(
|
||||
options.botManager, logger, options.database,
|
||||
|
||||
Reference in new issue
Block a user