diff --git a/README.md b/README.md index bb2bcfc..1b80cab 100644 --- a/README.md +++ b/README.md @@ -565,9 +565,10 @@ teamspeak-music-bot/ - 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制) - 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏 -- 不带平台标志的命令走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云) +- 不带平台标志的命令默认走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云) +- **自定义默认音源(`defaultPlatform`)** — 想让不带标志的 `!play 歌名` 直接用某个音源(例如常听哔哩哔哩,免去每次加 `-b`),可在 设置 → 默认音源 里选择,或在 `config.json` 中设置 `"defaultPlatform": "bilibili"`。取值须是 `enabledProviders` 里已启用的音源,否则被忽略(回退到上面的固定优先级);留空 / `null` / 删除该字段即恢复固定优先级。WebUI 保存后即时生效,无需重启 - 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动** -- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(此时默认音源仍为网易云,点歌用 `-j` 或停用网易云);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]` +- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(默认音源仍为网易云,点歌用 `-j`、停用网易云,或直接把 `defaultPlatform` 设为 `"jellyfin"`);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]` - 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表) ## 可选:YouTube 音源 diff --git a/src/data/config.test.ts b/src/data/config.test.ts index 9324348..08d263d 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -82,6 +82,61 @@ describe("config", () => { expect(defaultPlatform(config)).toBe("netease"); }); + // --- #126: an explicit operator default source --- + + it("defaultPlatform is null by default (follow the priority order)", () => { + expect(getDefaultConfig().defaultPlatform).toBeNull(); + }); + + it("defaultPlatform() honors an explicit, enabled preference over the priority order", () => { + const config = getDefaultConfig(); + // Priority would pick netease; a Bilibili-loving server sets B站 instead (#126). + config.defaultPlatform = "bilibili"; + expect(defaultPlatform(config)).toBe("bilibili"); + }); + + it("defaultPlatform() ignores a preference whose source is not enabled", () => { + const config = getDefaultConfig(); + config.defaultPlatform = "jellyfin"; // opt-in, not enabled in the default config + // Falls back to the fixed priority order (netease)… + expect(defaultPlatform(config)).toBe("netease"); + // …until the preferred source is actually enabled. + config.enabledProviders = [...config.enabledProviders, "jellyfin"]; + expect(defaultPlatform(config)).toBe("jellyfin"); + }); + + it("loadConfig keeps a valid, enabled defaultPlatform", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + writeFileSync(path, JSON.stringify({ defaultPlatform: "bilibili" })); + const config = loadConfig(path); + expect(config.defaultPlatform).toBe("bilibili"); + expect(defaultPlatform(config)).toBe("bilibili"); + }); + + it("loadConfig nulls a defaultPlatform that is unknown, disabled, or the wrong type", () => { + const dir = makeTmpDir(); + // Unknown provider name. + const p1 = join(dir, "c1.json"); + writeFileSync(p1, JSON.stringify({ defaultPlatform: "bogus" })); + expect(loadConfig(p1).defaultPlatform).toBeNull(); + // Known provider, but not in enabledProviders. + const p2 = join(dir, "c2.json"); + writeFileSync(p2, JSON.stringify({ enabledProviders: ["netease"], defaultPlatform: "bilibili" })); + expect(loadConfig(p2).defaultPlatform).toBeNull(); + // Wrong type. + const p3 = join(dir, "c3.json"); + writeFileSync(p3, JSON.stringify({ defaultPlatform: 42 })); + expect(loadConfig(p3).defaultPlatform).toBeNull(); + }); + + it("round-trips defaultPlatform through save/load", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + saveConfig(path, { ...getDefaultConfig(), defaultPlatform: "qq" }); + expect(loadConfig(path).defaultPlatform).toBe("qq"); + }); + it("respects an explicit jellyfin-only enabledProviders from disk", () => { const dir = makeTmpDir(); const path = join(dir, "config.json"); diff --git a/src/data/config.ts b/src/data/config.ts index 7e79c7c..6dc5cc2 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -77,15 +77,24 @@ export function isProviderEnabled(config: BotConfig, platform: string): boolean } /** - * The default platform for !play/!add/!playlist/!album and all REST/WebUI calls: - * the first enabled provider in a fixed priority order (netease with the default - * config; jellyfin ranks after the online music platforms because it is an - * opt-in source, but ahead of the video sites for users who run it as their - * only music library). Falls back to "netease" when nothing is enabled so - * callers always get a provider — the enabled-gate then produces the friendly - * error. + * The default platform for !play/!add/!playlist/!album and all REST/WebUI calls. + * + * An explicit user preference (`config.defaultPlatform`) wins whenever it points + * at a source that is currently enabled — this lets e.g. a Bilibili-loving server + * set B站 as the default so `!play <歌名>` needs no `-b` flag (issue #126). The + * enabled-guard here matters at runtime too: if the operator later disables the + * preferred source, we must fall through instead of returning a dead default. + * + * With no (usable) preference we fall back to the first enabled provider in a + * fixed priority order (netease with the default config; jellyfin ranks after + * the online music platforms because it is an opt-in source, but ahead of the + * video sites for users who run it as their only music library). Falls back to + * "netease" when nothing is enabled so callers always get a provider — the + * enabled-gate then produces the friendly error. */ export function defaultPlatform(config: BotConfig): GateableProvider { + const pref = config.defaultPlatform; + if (pref && config.enabledProviders.includes(pref)) return pref; for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) { if (config.enabledProviders.includes(p)) return p; } @@ -128,6 +137,14 @@ export interface BotConfig { * API servers must not start (or bind ports 3001/3200) unless enabled. */ enabledProviders: GateableProvider[]; + /** + * Optional operator-chosen default source for commands/REST/WebUI calls that + * omit a platform (issue #126). When set to an enabled gateable provider it + * overrides the fixed priority order in defaultPlatform(); `null` (the default) + * keeps that priority order. loadConfig cleans stale/unknown/disabled values + * back to null. + */ + defaultPlatform: GateableProvider | null; } export function getDefaultConfig(): BotConfig { @@ -190,6 +207,7 @@ export function getDefaultConfig(): BotConfig { jellyfin: "direct", }, enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"], + defaultPlatform: null, }; } @@ -344,6 +362,18 @@ export function loadConfig(path: string): BotConfig { ) : defaults.enabledProviders; + // defaultPlatform → an explicit operator default (issue #126). Keep it only + // when it names a KNOWN gateable provider that is ALSO currently enabled; + // anything else (unknown value, disabled source, wrong type, missing) becomes + // null so defaultPlatform() falls back to the fixed priority order. + const rawDefault = partial.defaultPlatform; + const defaultPlatformPref: GateableProvider | null = + typeof rawDefault === "string" && + (GATEABLE_PROVIDERS as readonly string[]).includes(rawDefault) && + enabledProviders.includes(rawDefault as GateableProvider) + ? (rawDefault as GateableProvider) + : null; + // 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). @@ -367,6 +397,7 @@ export function loadConfig(path: string): BotConfig { jellyfin, audioQuality, enabledProviders, + defaultPlatform: defaultPlatformPref, }; } } diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index eb174a6..346fede 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -647,4 +647,54 @@ describe("bot router /settings jellyfin block + enabledProviders", () => { // No jellyfin block in the request → no reconfigure call. expect(configureCalls).toHaveLength(0); }); + + // --- #126: operator-chosen default source --- + + it("GET /settings exposes defaultPlatform (null by default)", async () => { + const res = await request(mountBot()).get("/api/bot/settings"); + expect(res.status).toBe(200); + expect(res.body.defaultPlatform).toBeNull(); + }); + + it("POST /settings sets an enabled defaultPlatform and persists it", async () => { + const res = await request(mountBot()).post("/api/bot/settings").send({ + defaultPlatform: "bilibili", + }); + expect(res.status).toBe(200); + expect(res.body.defaultPlatform).toBe("bilibili"); + expect(config.defaultPlatform).toBe("bilibili"); + const onDisk = JSON.parse(readFileSync(configPath, "utf-8")); + expect(onDisk.defaultPlatform).toBe("bilibili"); + }); + + it("POST /settings ignores an unknown or disabled defaultPlatform", async () => { + const app = mountBot(); + // jellyfin is opt-in and not enabled in the default config → rejected. + await request(app).post("/api/bot/settings").send({ defaultPlatform: "jellyfin" }); + expect(config.defaultPlatform).toBeNull(); + // Unknown value → rejected. + await request(app).post("/api/bot/settings").send({ defaultPlatform: "bogus" }); + expect(config.defaultPlatform).toBeNull(); + }); + + it("POST /settings clears defaultPlatform with null", async () => { + const app = mountBot(); + await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" }); + expect(config.defaultPlatform).toBe("qq"); + const res = await request(app).post("/api/bot/settings").send({ defaultPlatform: null }); + expect(res.body.defaultPlatform).toBeNull(); + expect(config.defaultPlatform).toBeNull(); + }); + + it("POST /settings drops a default whose source gets disabled in the same request", async () => { + const app = mountBot(); + await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" }); + expect(config.defaultPlatform).toBe("qq"); + // Disabling qq via enabledProviders clears the now-invalid default. + const res = await request(app).post("/api/bot/settings").send({ + enabledProviders: ["netease", "bilibili"], + }); + expect(res.body.defaultPlatform).toBeNull(); + expect(config.defaultPlatform).toBeNull(); + }); }); diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index fd22b8d..d75bd6a 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -77,6 +77,7 @@ export function createBotRouter( spotify: maskedSpotify(), jellyfin: maskedJellyfin(), enabledProviders: config.enabledProviders, + defaultPlatform: config.defaultPlatform, }); }); @@ -175,6 +176,29 @@ export function createBotRouter( ); } + // defaultPlatform (issue #126): the operator-chosen default source for + // platform-less commands/REST/WebUI calls. Reconciled AFTER enabledProviders + // so both are validated against the same (possibly updated) enabled list: + // 1) Drop a stored default that the new enabledProviders no longer allows, + // keeping the persisted config consistent with loadConfig's invariant. + // 2) Apply an explicit change — `null`/`""` clears it (back to priority + // order); a known+enabled provider sets it; anything else is ignored. + if (config.defaultPlatform && !config.enabledProviders.includes(config.defaultPlatform)) { + config.defaultPlatform = null; + } + if ("defaultPlatform" in req.body) { + const dp = req.body.defaultPlatform; + if (dp === null || dp === "") { + config.defaultPlatform = null; + } else if ( + typeof dp === "string" && + (GATEABLE_PROVIDERS as readonly string[]).includes(dp) && + config.enabledProviders.includes(dp as GateableProvider) + ) { + config.defaultPlatform = dp as GateableProvider; + } + } + saveConfig(configPath, config); // Hot-apply the (possibly re-pointed) Jellyfin connection to the live @@ -219,6 +243,7 @@ export function createBotRouter( spotify: maskedSpotify(), jellyfin: maskedJellyfin(), enabledProviders: config.enabledProviders, + defaultPlatform: config.defaultPlatform, }); }); diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index ea170cc..8cd1297 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -128,6 +128,25 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints", expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off }); + it("GET /providers reports a configured defaultPlatform override (#126)", async () => { + const config = getDefaultConfig(); + config.defaultPlatform = "qq"; // operator prefers QQ over the priority order + const { app } = mount(config); + const res = await request(app).get("/api/music/providers"); + expect(res.status).toBe(200); + expect(res.body.default).toBe("qq"); + }); + + it("routes a platform-less /search to the configured defaultPlatform (#126)", async () => { + const config = getDefaultConfig(); + config.defaultPlatform = "bilibili"; + const { app, netease } = mount(config); + const res = await request(app).get("/api/music/search?q=hello"); + expect(res.status).toBe(200); + // Default is now bilibili, so the netease provider must NOT be hit. + expect(netease.search).not.toHaveBeenCalled(); + }); + /** Default config plus the opt-in jellyfin source enabled. */ function configWithJellyfin() { const config = getDefaultConfig(); diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 04b307d..4dc5af6 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -555,6 +555,36 @@ + + + 默认音源 + + 设置不带音源参数时(如聊天里的 !play 歌名 或网页搜索)默认使用的音源。 + 例如把默认音源设为「哔哩哔哩」后,点播 B 站音乐就不用每次都加 -b。 + 选择「自动」则按内置优先级挑选第一个已启用的音源(网易云 → QQ → 酷狗 → Jellyfin → 哔哩哔哩 → YouTube)。 + + + + + 默认音源 + + + + 自动(按优先级) + + {{ opt.label }} + + + + {{ defaultSourceSaving ? '保存中…' : '保存' }} + + + + {{ defaultSourceMessage }} + + Spotify 播放(实验性) @@ -1161,6 +1191,51 @@ function providerOn(p: string): boolean { return enabledProviders.value.length === 0 || enabledProviders.value.includes(p); } +// --- Default music source (issue #126) --- +// Chinese labels for the gateable providers, shown in the default-source select. +const PROVIDER_LABELS: Record = { + netease: '网易云音乐', + qq: 'QQ音乐', + kugou: '酷狗音乐', + bilibili: '哔哩哔哩', + youtube: 'YouTube', + jellyfin: 'Jellyfin', +}; +// Empty string = "auto" (follow the fixed priority order); persisted as null. +const defaultPlatformForm = ref(''); +const defaultSourceSaving = ref(false); +const defaultSourceMessage = ref(''); +const defaultSourceMessageTone = ref<'ok' | 'warn'>('ok'); +// Only currently-enabled sources can be picked as the default. +const defaultSourceOptions = computed(() => + enabledProviders.value + .filter((p) => p in PROVIDER_LABELS) + .map((p) => ({ value: p, label: PROVIDER_LABELS[p] })), +); + +async function saveDefaultSource() { + defaultSourceSaving.value = true; + defaultSourceMessage.value = ''; + try { + const res = await axios.post('/api/bot/settings', { + defaultPlatform: defaultPlatformForm.value || null, + }); + defaultPlatformForm.value = res.data?.defaultPlatform ?? ''; + // Push the new default across the app immediately (search bar / play calls + // read store.defaultSource, refreshed via GET /api/music/providers). + await store.fetchProviders(); + defaultSourceMessageTone.value = 'ok'; + defaultSourceMessage.value = '已保存'; + } catch (err: any) { + defaultSourceMessageTone.value = 'warn'; + defaultSourceMessage.value = err?.response?.status === 403 + ? '没有权限修改设置(需要 bot.manage)' + : '保存失败,请稍后重试'; + } finally { + defaultSourceSaving.value = false; + } +} + // --- Jellyfin connection (admin-configured; password/apiKey are write-only) --- const jellyfinForm = reactive({ serverUrl: '', @@ -1217,6 +1292,11 @@ async function saveJellyfin() { // Search bar / home sections / FM cards react without a reload. store.fetchProviders(); } + // Disabling a source can clear a default that pointed at it (backend + // reconciles enabledProviders → defaultPlatform); keep the select in sync. + if (res.data && 'defaultPlatform' in res.data) { + defaultPlatformForm.value = res.data.defaultPlatform ?? ''; + } jellyfinMessageTone.value = 'ok'; jellyfinMessage.value = '已保存'; await checkAuthStatus(); @@ -1506,6 +1586,8 @@ async function loadIdleTimeout() { jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin'); enabledProvidersLoaded.value = true; } + // null (unset) → "" so the select shows "自动(按优先级)". + defaultPlatformForm.value = res.data.defaultPlatform ?? ''; } catch { /* ignore */ } } @@ -2448,6 +2530,13 @@ onUnmounted(() => { .input-sm { max-width: 80px; } +// The default-source picker holds full source names ("网易云音乐"), so it needs +// more room than the 80px .input-sm cap. +.default-source-select { + max-width: none; + flex: 0 0 160px; +} + .textarea { width: 100%; padding: 10px 14px;
+ 设置不带音源参数时(如聊天里的 !play 歌名 或网页搜索)默认使用的音源。 + 例如把默认音源设为「哔哩哔哩」后,点播 B 站音乐就不用每次都加 -b。 + 选择「自动」则按内置优先级挑选第一个已启用的音源(网易云 → QQ → 酷狗 → Jellyfin → 哔哩哔哩 → YouTube)。 +
!play 歌名
-b
{{ defaultSourceMessage }}