diff --git a/README.md b/README.md index 82f90a0..bb2bcfc 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,20 @@ sudo systemctl start tsmusicbot 在设置页面选择音质,立即生效(影响后续播放的歌曲)。 +> **重启后保留(#125)**:音质选择会持久化到 `data/config.json`(每个平台各自记录),重启机器人后自动恢复,无需每次手动重设。 + +### 重启后保留的播放设置 + +以下运行时设置在改动时自动落盘,重启机器人后自动恢复,不再回到默认值: + +| 设置 | 作用范围 | 存储位置 | +|------|----------|----------| +| **播放音量**(`!vol` / WebUI 音量条 / REST `/volume`) | 每个机器人独立 | 数据库 `bot_instances.volume` | +| **播放模式**(`!mode` / WebUI / REST `/mode`:顺序 / 列表循环 / 随机 / 随机循环) | 每个机器人独立 | 数据库 `bot_instances.play_mode` | +| **音质**(各平台,WebUI 设置页 / REST `/quality`) | 全局(各平台各自记录) | `data/config.json` 的 `audioQuality` | + +聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。 + ## 项目架构 ``` diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index bb5aa77..9f12883 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -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 "); + 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: [] }); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index baaf8f1..29cfdf9 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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 = { + 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 = { - 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 "; 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}`; } diff --git a/src/data/config.test.ts b/src/data/config.test.ts index 7fbce19..9324348 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -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"); diff --git a/src/data/config.ts b/src/data/config.ts index b5b515f..7e79c7c 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -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; + 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, }; } diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 7520a4f..8352215 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -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", diff --git a/src/data/database.ts b/src/data/database.ts index 992fc94..2bb5a0c 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 008a318..d7d14d1 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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). diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index 451dca1..ea170cc 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -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; + + /** 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"); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 2f8363a..ff1455d 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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 }); }); diff --git a/src/web/server.ts b/src/web/server.ts index 60bbfd0..2fbc548 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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,