feat: make Jellyfin an optional source instead of the default

Revert the jellyfin-only default introduced by PR #123 so upgrading
users keep their online sources; Jellyfin becomes opt-in:

- default enabledProviders is now the online set (netease/qq/bilibili/
  youtube/kugou); defaultPlatform() uses a fixed priority order
  (netease -> qq -> kugou -> jellyfin -> bilibili -> youtube) instead
  of jellyfin-first, so chat/REST/WebUI default to netease again
- Settings: Jellyfin card is always visible with a new enable toggle
  (its enabled bit is enabledProviders membership); guards against
  clobbering other providers before the list loads
- Setup wizard: saving the Jellyfin step auto-enables the source when
  a server URL was entered
- Search/player store fallbacks flip from jellyfin to netease; !help
  no longer hardcodes Jellyfin lines
- tests: update default-platform assertions, add coverage for the new
  default set, legacy configs without enabledProviders, priority
  order, and explicit jellyfin-only configs
- README: reframe Jellyfin as optional (badges, command table, quality
  tiers, dedicated section, changelog), document the enabledProviders
  default and the v1.10.0 jellyfin-only window fix, credit @ItsEricRao

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Fable 5 committed 2026-07-14 00:54:34 +08:00
1 parent 3841fa80d3
commit 750ad9b1cc
12 files changed
+270 -72

No files matched your search

+3 -3
View File
@@ -113,14 +113,14 @@ export const usePlayerStore = defineStore('player', {
// Which sources the server has enabled (GET /api/music/providers) and the
// configured default. Empty until fetched — sections stay hidden briefly.
enabledProviders: [] as string[],
defaultSource: 'jellyfin' as string,
defaultSource: 'netease' as string,
// Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
userPlaylists: { jellyfin: [] as PlaylistItem[], netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[],
// Jellyfin home sections (primary source)
// Jellyfin home sections (shown only when the optional source is enabled)
jellyfinLatestAlbums: [] as AlbumItem[],
jellyfinMostPlayed: [] as Song[],
jellyfinFavorites: [] as Song[],
@@ -173,7 +173,7 @@ export const usePlayerStore = defineStore('player', {
const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
/** Sources that are currently logged in. Jellyfin (the primary source) leads. */
/** Sources that are currently logged in. Jellyfin (when enabled) leads. */
availableSources(): Source[] {
const s: Source[] = [];
if (this.authStatus.jellyfin) s.push('jellyfin');
+3 -3
View File
@@ -57,7 +57,7 @@
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
— the primary source — comes first. -->
(opt-in) comes first when enabled. -->
<div class="source-bar">
<button
v-if="sourceEnabled('jellyfin')"
@@ -218,7 +218,7 @@ function loadSource(): SearchSource {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (SEARCH_SOURCES.includes(stored as SearchSource)) return stored as SearchSource;
} catch { /* localStorage blocked */ }
return 'jellyfin';
return 'netease';
}
type TabType = 'songs' | 'albums' | 'playlists';
@@ -272,7 +272,7 @@ function fixupSelectedSource() {
const def = store.defaultSource as SearchSource;
selectedSource.value = SEARCH_SOURCES.includes(def) && sourceEnabled(def)
? def
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'jellyfin';
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'netease';
}
// ---- 分页 / 加载更多 ----
+39 -5
View File
@@ -165,10 +165,13 @@
</div>
</section>
<!-- Jellyfin — the primary music source. Admin-configured connection
(server URL + credentials), no QR flow. -->
<section v-if="can('platform.auth') && providerOn('jellyfin')" class="settings-section">
<h2 class="section-title">Jellyfin 音乐库</h2>
<!-- Jellyfin — optional self-hosted music source. Admin-configured
connection (server URL + credentials), no QR flow. The card stays
visible even while the source is disabled: the enable toggle below is
the only UI that flips its enabledProviders membership, so hiding the
card would leave no way to turn Jellyfin on. -->
<section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">Jellyfin 音乐库(可选)</h2>
<div class="account-card">
<div class="account-header">
@@ -181,6 +184,15 @@
</div>
</div>
<!-- Enable (opt-in source: presence in enabledProviders) -->
<label class="profile-toggle behavior-toggle spotify-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">启用 Jellyfin 音源</div>
<div class="profile-toggle-hint">开启后 Jellyfin 出现在搜索、首页与聊天命令(!play -j)的音源列表中。默认关闭,点击下方「保存」后生效。</div>
</div>
<input v-model="jellyfinEnabledForm" type="checkbox" class="profile-toggle-switch" />
</label>
<div class="form-group">
<label>服务器地址</label>
<input v-model="jellyfinForm.serverUrl" class="input" autocomplete="off" placeholder="https://jellyfin.example.com" />
@@ -1164,6 +1176,12 @@ const jellyfinSaving = ref(false);
const jellyfinTesting = ref(false);
const jellyfinMessage = ref('');
const jellyfinMessageTone = ref<'ok' | 'warn'>('ok');
// Jellyfin is opt-in: its "enabled" bit is membership in enabledProviders
// (unlike Spotify's dedicated spotify.enabled flag). Only trust the toggle
// once the real list has loaded, so an early save can't clobber the other
// providers with the empty placeholder list.
const jellyfinEnabledForm = ref(false);
const enabledProvidersLoaded = ref(false);
// Populate from the masked GET /api/bot/settings response — secrets never
// round-trip, only hasPassword/hasApiKey.
@@ -1183,8 +1201,22 @@ async function saveJellyfin() {
jellyfinSaving.value = true;
jellyfinMessage.value = '';
try {
const res = await axios.post('/api/bot/settings', { jellyfin: { ...jellyfinForm } });
// enabledProviders is a full-replace field, so only send it when the
// current list is known — otherwise we'd wipe the other sources.
const payload: Record<string, unknown> = { jellyfin: { ...jellyfinForm } };
if (enabledProvidersLoaded.value) {
const others = enabledProviders.value.filter((p) => p !== 'jellyfin');
payload.enabledProviders = jellyfinEnabledForm.value ? [...others, 'jellyfin'] : others;
}
const res = await axios.post('/api/bot/settings', payload);
applyJellyfinConfig(res.data?.jellyfin);
if (Array.isArray(res.data?.enabledProviders)) {
enabledProviders.value = res.data.enabledProviders;
jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
enabledProvidersLoaded.value = true;
// Search bar / home sections / FM cards react without a reload.
store.fetchProviders();
}
jellyfinMessageTone.value = 'ok';
jellyfinMessage.value = '已保存';
await checkAuthStatus();
@@ -1471,6 +1503,8 @@ async function loadIdleTimeout() {
applyJellyfinConfig(res.data.jellyfin);
if (Array.isArray(res.data.enabledProviders)) {
enabledProviders.value = res.data.enabledProviders;
jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
enabledProvidersLoaded.value = true;
}
} catch { /* ignore */ }
}
+16 -4
View File
@@ -61,7 +61,7 @@
<div v-if="currentStep === 2" class="step-content">
<h2>连接 Jellyfin (可选)</h2>
<p class="subtitle">连接自建 Jellyfin 服务器作为主音源;也可稍后在「设置」中配置</p>
<p class="subtitle">连接自建 Jellyfin 服务器作为额外音源;保存后自动启用,也可稍后在「设置」中配置</p>
<div class="form-group">
<label>服务器地址</label>
<input v-model="jellyfin.serverUrl" placeholder="https://jellyfin.example.com" class="input" />
@@ -130,8 +130,8 @@ const nickname = ref('MusicBot');
const defaultChannel = ref('');
const channelId = ref('');
// Jellyfin — the primary music source. Optional: skipping leaves it
// configurable later in Settings.
// Jellyfin — optional self-hosted music source. Skipping leaves it
// configurable later in Settings; saving here also enables it.
const jellyfin = reactive({
serverUrl: '',
authMode: 'userpass' as 'userpass' | 'apikey',
@@ -182,7 +182,19 @@ async function testJellyfin() {
async function saveJellyfinAndNext() {
jellyfinSaving.value = true;
try {
await axios.post('/api/bot/settings', { jellyfin: { ...jellyfin } });
// Jellyfin is opt-in (not in the default enabledProviders), so completing
// this step also enables the source — a configured-but-dark Jellyfin would
// be baffling. enabledProviders is full-replace: fetch the current list
// and append. Only do this when a server URL was actually entered.
const payload: Record<string, unknown> = { jellyfin: { ...jellyfin } };
if (jellyfin.serverUrl.trim()) {
const cur = await axios.get('/api/bot/settings');
const ep: unknown = cur.data?.enabledProviders;
if (Array.isArray(ep) && !ep.includes('jellyfin')) {
payload.enabledProviders = [...ep, 'jellyfin'];
}
}
await axios.post('/api/bot/settings', payload);
currentStep.value = 3;
} catch {
jellyfinTestOk.value = false;