feat: add configurable default music source (#126)

Make the default playback source a user-configurable setting so servers
that mostly play e.g. Bilibili no longer need to type `-b` on every
`!play`. Previously defaultPlatform() always picked the first enabled
provider by a fixed priority order, with no way to override it.

- config: add optional `defaultPlatform: GateableProvider | null`.
  loadConfig sanitizes it — kept only when it names a known provider that
  is also currently enabled, else null. defaultPlatform() returns the
  preference when enabled, otherwise falls back to the fixed priority order.
- POST /api/bot/settings accepts `defaultPlatform` (validated against the
  possibly-updated enabledProviders; null/"" clears it), and reconciles a
  stored default that a new enabledProviders list no longer allows. GET and
  POST responses expose the field.
- WebUI: new "默认音源" section with a source picker; saving refreshes the
  store's default source so it takes effect immediately without a restart.
- Tests: extend config defaultPlatform priority tests and add coverage for
  the settings endpoint and /providers routing.
- README: document `defaultPlatform` in the enabledProviders section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Fable 5 committed 2026-07-16 23:43:39 +08:00
1 parent 051171b019
commit 987513a5f6
7 files changed
+279 -9

No files matched your search

+89
View File
@@ -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;