mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #131 from ZHANGTIANYAO1/feat/issue-126-default-source
feat: add configurable default music source (#126)
This commit is contained in:
7 files changed
+279
-9
No files matched your search
@@ -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 音源
|
||||
|
||||
@@ -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");
|
||||
|
||||
+38
-7
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -555,6 +555,36 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Default music source (issue #126): the source used by chat commands
|
||||
(!play/!add …) and the WebUI when no platform flag is given. Saving is a
|
||||
global bot setting, so gate on bot.manage like the other behavior rows. -->
|
||||
<section v-if="can('bot.manage')" class="settings-section">
|
||||
<h2 class="section-title">默认音源</h2>
|
||||
<p class="profile-section-hint">
|
||||
设置不带音源参数时(如聊天里的 <code>!play 歌名</code> 或网页搜索)默认使用的音源。
|
||||
例如把默认音源设为「哔哩哔哩」后,点播 B 站音乐就不用每次都加 <code>-b</code>。
|
||||
选择「自动」则按内置优先级挑选第一个已启用的音源(网易云 → QQ → 酷狗 → Jellyfin → 哔哩哔哩 → YouTube)。
|
||||
</p>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:music-box-multiple-outline" class="setting-icon" />
|
||||
默认音源
|
||||
</div>
|
||||
<div class="prefix-input-wrap">
|
||||
<select v-model="defaultPlatformForm" class="input input-sm default-source-select">
|
||||
<option value="">自动(按优先级)</option>
|
||||
<option v-for="opt in defaultSourceOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn-primary" :disabled="defaultSourceSaving" @click="saveDefaultSource">
|
||||
{{ defaultSourceSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="defaultSourceMessage" class="spotify-message" :class="`tone-${defaultSourceMessageTone}`">{{ defaultSourceMessage }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Spotify (Connect) playback via librespot — requires platform.auth -->
|
||||
<section v-if="can('platform.auth')" class="settings-section">
|
||||
<h2 class="section-title">Spotify 播放(实验性)</h2>
|
||||
@@ -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<string, string> = {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user