Files
teamspeak-music-bot/web/src/stores/player.ts
T
saopig1andClaude Fable 5 0b10f9553c feat(#119): saved queues web UI + behavior-settings toggles
- Settings → 行为设置: two new toggles (保存/加载播放清单, 单曲直接播放不清空队列)
  that round-trip savedQueuesEnabled / playKeepsQueue and keep the nav gate in sync.
- New "已存队列" page (/saved-queues): save the current queue (with a 共享 option),
  load (replace) / append / delete saved queues; renders a "feature disabled"
  state on 403 so it degrades gracefully when the flag is off.
- Nav entry gated on the savedQueuesEnabled store flag (hidden for guests).
- useSavedQueues API composable + a pure, unit-tested list/ownership helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:29:00 +08:00

831 lines
31 KiB
TypeScript

import { defineStore } from 'pinia';
import axios from 'axios';
import { resolveScopedBot } from './scope.js';
import { useSession } from '../composables/useSession.js';
export interface Song {
id: string;
name: string;
artist: string;
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify' | 'jellyfin';
requestedBy?: string;
playedAt?: string;
}
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
export interface AlbumItem {
id: string;
name: string;
artist: string;
coverUrl: string;
songCount: number;
platform: string;
}
export interface BotStatus {
id: string;
name: string;
connected: boolean;
playing: boolean;
paused: boolean;
currentSong: Song | null;
queueSize: number;
volume: number;
playMode: string;
elapsed?: number;
}
export interface PlaylistItem {
id: string;
name: string;
coverUrl: string;
songCount: number;
platform: string;
}
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
export interface TimingState {
serverElapsed: number;
serverSyncTime: number;
wasPlaying: boolean;
}
const HOME_CACHE_TTL = 5 * 60 * 1000;
function defaultTiming(): TimingState {
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
}
/**
* Interpolate the live elapsed seconds from the last server anchor.
*
* This is a PURE function (its only time source is `Date.now()`), deliberately
* kept OUT of the Pinia getter so it can be called fresh every animation frame.
* The `elapsed` getter is a Vue `computed` and caches its result until a
* REACTIVE dependency changes — but `Date.now()` is not reactive, so a getter
* only re-runs on a WebSocket push / server poll (every few seconds). Reading
* the getter from a requestAnimationFrame loop therefore returns a frozen value
* and the clock appears to jump ~3s at a time (issue #107). Per-frame consumers
* must call this helper (via the `liveElapsed` action) instead.
*/
export function interpolateElapsed(
timing: TimingState,
isPaused: boolean,
maxDuration: number,
): number {
// No live anchor yet, or paused: report the frozen server position.
if (!timing.wasPlaying || timing.serverSyncTime === 0 || isPaused) {
return Math.min(timing.serverElapsed, maxDuration);
}
return Math.min(
timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000,
maxDuration,
);
}
export const usePlayerStore = defineStore('player', {
state: () => ({
bots: [] as BotStatus[],
activeBotId: null as string | null,
/** When set, the UI is locked to a single bot (dedicated link, from ?bot).
* Source of truth is the URL — never persisted to localStorage. */
scopedBotId: null as string | null,
/** Per-bot queues keyed by botId */
queues: {} as Record<string, Song[]>,
/** Per-bot timing state keyed by botId */
timings: {} as Record<string, TimingState>,
theme: 'dark' as 'dark' | 'light',
// 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: 'netease' as string,
// Whether the admin enabled save/load queues (#119). Gates the nav entry
// and the /saved-queues page. Fetched from /api/bot/settings (non-guest).
savedQueuesEnabled: false,
// 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 (shown only when the optional source is enabled)
jellyfinLatestAlbums: [] as AlbumItem[],
jellyfinMostPlayed: [] as Song[],
jellyfinFavorites: [] as Song[],
jellyfinGenres: [] as { id: string; name: string }[],
authStatus: { jellyfin: false, netease: false, qq: false, kugou: false, spotify: false },
lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user)
favoritedPlaylists: [] as FavoritePlaylist[],
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
}),
getters: {
activeBot(): BotStatus | null {
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
},
/** True when the UI is locked to a single bot via a dedicated link. */
isScoped(): boolean {
return this.scopedBotId !== null;
},
currentSong(): Song | null {
return this.activeBot?.currentSong ?? null;
},
isPlaying(): boolean {
return this.activeBot?.playing ?? false;
},
isPaused(): boolean {
return this.activeBot?.paused ?? false;
},
/** Queue for the currently active bot */
queue(): Song[] {
const botId = this.activeBotId ?? this.bots[0]?.id;
if (!botId) return [];
return this.queues[botId] ?? [];
},
/**
* Interpolated elapsed for the active bot. NOTE: as a Pinia getter this is
* a Vue `computed` and is CACHED — it only re-runs when a reactive
* dependency changes, so it does NOT tick every second on its own. Use it
* for one-off reactive reads; per-frame consumers (progress bar, lyrics)
* must call the `liveElapsed` action so the clock advances smoothly (#107).
*/
elapsed(): number {
const botId = this.activeBotId ?? this.bots[0]?.id;
if (!botId || !this.activeBot?.currentSong) return 0;
const timing = this.timings[botId] ?? defaultTiming();
const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
/** Sources that are currently logged in. Jellyfin (when enabled) leads. */
availableSources(): Source[] {
const s: Source[] = [];
if (this.authStatus.jellyfin) s.push('jellyfin');
if (this.authStatus.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq');
if (this.authStatus.kugou) s.push('kugou');
if (this.authStatus.spotify) s.push('spotify');
return s;
},
/** Whether a source is enabled server-side (enabledProviders gate). */
sourceEnabled(): (platform: string) => boolean {
return (platform: string) => this.enabledProviders.includes(platform);
},
},
actions: {
/**
* Live elapsed seconds for the active bot, recomputed on every call. Unlike
* the `elapsed` getter (a cached computed), this is an action, so it is NOT
* memoised — call it from requestAnimationFrame / interval loops so the
* progress bar and lyrics advance every frame instead of jumping on each
* server push (#107).
*/
liveElapsed(): number {
const botId = this.activeBotId ?? this.bots[0]?.id;
if (!botId || !this.activeBot?.currentSong) return 0;
const timing = this.timings[botId] ?? defaultTiming();
const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
_getTiming(botId: string): TimingState {
if (!this.timings[botId]) {
this.timings[botId] = defaultTiming();
}
return this.timings[botId];
},
_setTiming(botId: string, partial: Partial<TimingState>) {
const current = this._getTiming(botId);
this.timings[botId] = { ...current, ...partial };
},
getQueueForBot(botId: string): Song[] {
return this.queues[botId] ?? [];
},
setActiveBotId(id: string) {
// While scoped to a dedicated link, switching bots is blocked.
if (this.scopedBotId !== null && id !== this.scopedBotId) return;
this.activeBotId = id;
// Fetch queue for newly active bot if we don't have it yet
if (!this.queues[id]) {
this.fetchQueue();
}
},
/** Lock the UI to a single bot (dedicated link). Sets scope first so the
* setActiveBotId guard does not block the switch to the scoped bot. */
setScope(id: string) {
this.scopedBotId = id;
this.activeBotId = id;
// Lazily fetch this bot's queue, mirroring setActiveBotId.
if (!this.queues[id]) {
this.fetchQueue();
}
},
clearScope() {
this.scopedBotId = null;
},
/** Reconcile the scope with the desired id from the URL (?bot). A stale or
* forbidden id resolves to null and clears the scope rather than locking. */
applyScopeFromQuery(requestedId: string | null) {
const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id));
if (r) {
this.setScope(r);
} else if (requestedId) {
this.clearScope();
}
},
updateBotStatus(botId: string, status: BotStatus) {
const prev = this.bots.find((b) => b.id === botId);
const prevSongId = prev?.currentSong?.id;
const index = this.bots.findIndex((b) => b.id === botId);
if (index >= 0) {
this.bots[index] = status;
} else {
this.bots.push(status);
}
// Sync elapsed from server status — always per-bot
if (status.elapsed !== undefined) {
this._setTiming(botId, {
serverElapsed: status.elapsed,
serverSyncTime: Date.now(),
wasPlaying: status.playing && !status.paused,
});
}
// Song changed — reset timing for this bot
if (status.currentSong?.id !== prevSongId) {
this._setTiming(botId, {
serverElapsed: status.elapsed ?? 0,
serverSyncTime: Date.now(),
wasPlaying: status.playing && !status.paused,
});
}
},
removeBotStatus(botId: string) {
this.bots = this.bots.filter((b) => b.id !== botId);
delete this.queues[botId];
delete this.timings[botId];
// If the bot we were locked to is gone, drop the scope so the UI does not
// stay 'locked' onto a phantom (activeBot would silently fall back to bots[0]).
if (this.scopedBotId === botId) {
this.clearScope();
}
},
setQueue(botId: string, queue: Song[]) {
this.queues[botId] = queue;
},
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', this.theme);
},
loadTheme() {
const saved = localStorage.getItem('theme') as 'dark' | 'light' | null;
if (saved) this.theme = saved;
},
async startBotInstance(id: string) {
await axios.post(`/api/bot/${id}/start`);
},
async stopBotInstance(id: string) {
await axios.post(`/api/bot/${id}/stop`);
},
async fetchBots() {
const res = await axios.get('/api/bot');
this.bots = res.data.bots;
if (!this.activeBotId && this.bots.length > 0) {
this.activeBotId = this.bots[0].id;
}
// Sync elapsed from each bot's status
for (const bot of this.bots) {
if (bot.elapsed !== undefined) {
this._setTiming(bot.id, {
serverElapsed: bot.elapsed,
serverSyncTime: Date.now(),
wasPlaying: bot.playing && !bot.paused,
});
}
}
},
/** Poll server for real elapsed time for active bot */
async syncElapsed() {
if (!this.activeBotId || !this.isPlaying) return;
try {
const res = await axios.get(`/api/player/${this.activeBotId}/elapsed`);
this._setTiming(this.activeBotId, {
serverElapsed: res.data.elapsed,
serverSyncTime: Date.now(),
wasPlaying: true,
});
} catch {
// ignore
}
},
async fetchQueue() {
if (!this.activeBotId) return;
try {
const res = await axios.get(`/api/player/${this.activeBotId}/queue`);
this.queues[this.activeBotId] = res.data.queue ?? [];
} catch {
// ignore
}
},
async fetchQueueForBot(botId: string) {
try {
const res = await axios.get(`/api/player/${botId}/queue`);
this.queues[botId] = res.data.queue ?? [];
} catch {
// ignore
}
},
_syncAfterAction() {
if (!this.activeBotId) return;
this._setTiming(this.activeBotId, {
serverSyncTime: Date.now(),
wasPlaying: true,
});
// Sync from server after a short delay for accuracy
setTimeout(() => this.syncElapsed(), 500);
},
async playAtIndex(index: number) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-at`, { index });
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async play(query: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform });
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async playById(songId: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-by-id`, { songId, platform });
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
notify(message: string, type: 'error' | 'info' = 'info') {
this.notification = { id: Date.now(), message, type };
},
async playSong(song: Song) {
if (!this.activeBotId) return;
// Guests use the non-destructive "play now" (insert-next + skip) so they
// can't wipe everyone else's queue; members/admins keep the normal behavior.
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
const res = await axios.post(`/api/player/${this.activeBotId}/${endpoint}`, { song });
if (res.data?.ok === false && res.data?.message) {
this.notify(res.data.message, 'error');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async playNextSong(song: Song) {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
// Refresh queue so the inserted item shows up in the side panel
this.fetchQueue();
},
async addToQueue(query: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
},
async addToQueueById(songId: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
},
async addSong(song: Song) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
},
async playPlaylist(playlistId: string, platform = 'netease') {
if (!this.activeBotId) return;
try {
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
} catch (e: any) {
// A 403 here means a guest lacks the "play entire collection" permission
// (issue #103) — surface it instead of failing silently.
this.notify(e?.response?.status === 403 ? '没有权限播放整个歌单' : '播放歌单失败', 'error');
}
},
async playAlbum(albumId: string, platform = 'netease') {
if (!this.activeBotId) return;
try {
const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
} catch (e: any) {
this.notify(e?.response?.status === 403 ? '没有权限播放整个专辑' : '播放专辑失败', 'error');
}
},
async pause() {
if (!this.activeBotId) return;
// Freeze elapsed at the current LIVE interpolated value. Using the cached
// `elapsed` getter here could snapshot a value up to a few seconds stale.
this._setTiming(this.activeBotId, {
serverElapsed: this.liveElapsed(),
wasPlaying: false,
});
await axios.post(`/api/player/${this.activeBotId}/pause`);
},
async resume() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/resume`);
this._setTiming(this.activeBotId, {
serverSyncTime: Date.now(),
wasPlaying: true,
});
setTimeout(() => this.syncElapsed(), 300);
},
async next() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/next`);
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async prev() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/prev`);
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async stop() {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/stop`);
this._setTiming(this.activeBotId, {
serverElapsed: 0,
serverSyncTime: 0,
wasPlaying: false,
});
},
async seek(position: number) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/seek`, { position });
this._setTiming(this.activeBotId, { serverElapsed: position });
this._syncAfterAction();
},
async setVolume(volume: number) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/volume`, { volume });
const bot = this.bots.find((b) => b.id === this.activeBotId);
if (bot) bot.volume = volume;
},
async setMode(mode: string) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/mode`, { mode });
const bot = this.bots.find((b) => b.id === this.activeBotId);
if (bot) bot.playMode = mode;
},
async startFm(platform: Source = 'netease') {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
this.fetchQueue();
},
async fetchFavorites() {
try {
const res = await axios.get('/api/favorites');
this.favoritedPlaylists = res.data.favorites ?? [];
} catch {
// not critical
}
},
async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) {
try {
await axios.post('/api/favorites', playlist);
await this.fetchFavorites();
this.notify('已收藏', 'info');
} catch (err: any) {
// 409 = already favorited (e.g. stale heart); just resync so the UI converges.
if (err?.response?.status === 409) {
await this.fetchFavorites();
return;
}
this.notify('收藏失败', 'error');
}
},
async removeFavorite(id: number) {
try {
await axios.delete(`/api/favorites/${id}`);
await this.fetchFavorites();
this.notify('已取消收藏', 'info');
} catch (err: any) {
// 404 = already gone; resync. Otherwise report failure.
if (err?.response?.status === 404) {
await this.fetchFavorites();
return;
}
this.notify('取消收藏失败', 'error');
}
},
isFavorited(playlistId: string, platform: string): boolean {
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
},
/** Load global bot settings we care about client-side (currently just the
* saved-queues gate). Guests get a 403 here, which we swallow so the nav
* entry simply stays hidden for them. */
async fetchBotSettings() {
try {
const res = await axios.get('/api/bot/settings');
this.savedQueuesEnabled = res.data?.savedQueuesEnabled === true;
} catch {
// not critical (guests 403; keep previous value otherwise)
}
},
/** Load the server-side source gate (enabledProviders + default). */
async fetchProviders() {
try {
const res = await axios.get('/api/music/providers');
this.enabledProviders = res.data.enabled ?? [];
if (typeof res.data.default === 'string') this.defaultSource = res.data.default;
} catch {
// keep previous values (backend briefly unavailable)
}
},
async fetchHomeData() {
// The provider gate decides which sources to even ask about — disabled
// sources are skipped entirely (their sidecars may not be running).
await this.fetchProviders();
const has = (p: string) => this.enabledProviders.includes(p);
const skip = Promise.reject(new Error('provider disabled'));
// Pre-rejected sentinel: mark handled so it never surfaces as unhandled.
skip.catch(() => {});
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch.
const [jfAuthRes, neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
has('jellyfin') ? axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }) : skip,
has('netease') ? axios.get('/api/auth/status', { params: { platform: 'netease' } }) : skip,
has('qq') ? axios.get('/api/auth/status', { params: { platform: 'qq' } }) : skip,
has('kugou') ? axios.get('/api/auth/status', { params: { platform: 'kugou' } }) : skip,
has('spotify') ? axios.get('/api/auth/status', { params: { platform: 'spotify' } }) : skip,
]);
const newAuth = {
jellyfin: jfAuthRes.status === 'fulfilled' && !!jfAuthRes.value.data?.loggedIn,
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
};
const authChanged =
newAuth.jellyfin !== this.authStatus.jellyfin ||
newAuth.netease !== this.authStatus.netease ||
newAuth.qq !== this.authStatus.qq ||
newAuth.kugou !== this.authStatus.kugou ||
newAuth.spotify !== this.authStatus.spotify;
this.authStatus.jellyfin = newAuth.jellyfin;
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
this.authStatus.kugou = newAuth.kugou;
this.authStatus.spotify = newAuth.spotify;
// Favorites are user-local and cheap; always refresh them, even on a
// home-data cache hit, so hearts stay correct across tabs/sessions.
this.fetchFavorites();
// Cache hit only if auth is unchanged AND within TTL.
if (
!authChanged &&
this.lastFetchTime > 0 &&
Date.now() - this.lastFetchTime < HOME_CACHE_TTL
) {
return;
}
// 2. NetEase data: recommend playlists work anonymously; daily/user
// playlists need login but Promise.allSettled isolates failures.
const emptyPlaylists = { data: { playlists: [] } };
const emptySongs = { data: { songs: [] } };
const neteasePromises = has('netease')
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
];
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
// resolve to empty payloads so the same indexed handling works.
const qqPromises = has('qq') && this.authStatus.qq
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'qq' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
];
// 4. Kugou data: every section (incl. recommend playlists) needs login,
// so gate all three on auth like QQ.
const kugouPromises = has('kugou') && this.authStatus.kugou
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'kugou' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
];
const biliPromise = has('bilibili')
? axios.get('/api/music/bilibili/popular?limit=12')
: Promise.resolve(emptySongs);
// 5. Jellyfin home sections. Favorites are guest-blocked server-side;
// allSettled turns that 403 into an empty section.
const jellyfinPromises = has('jellyfin')
? [
axios.get('/api/music/jellyfin/latest-albums', { params: { limit: 12 } }),
axios.get('/api/music/jellyfin/most-played', { params: { limit: 12 } }),
axios.get('/api/music/jellyfin/favorites', { params: { limit: 12 } }),
axios.get('/api/music/jellyfin/genres'),
axios.get('/api/music/user/playlists', { params: { platform: 'jellyfin' } }),
]
: [
Promise.resolve({ data: { albums: [] } }),
Promise.resolve(emptySongs),
Promise.resolve(emptySongs),
Promise.resolve({ data: { genres: [] } }),
Promise.resolve(emptyPlaylists),
];
const results = await Promise.allSettled([
...neteasePromises,
...qqPromises,
...kugouPromises,
biliPromise,
...jellyfinPromises,
]);
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili,
jfLatest, jfMost, jfFav, jfGenres, jfUserPL] = results;
this.jellyfinLatestAlbums =
jfLatest.status === 'fulfilled' ? (jfLatest.value.data.albums ?? []) : [];
this.jellyfinMostPlayed =
jfMost.status === 'fulfilled' ? (jfMost.value.data.songs ?? []) : [];
this.jellyfinFavorites =
jfFav.status === 'fulfilled' ? (jfFav.value.data.songs ?? []) : [];
this.jellyfinGenres =
jfGenres.status === 'fulfilled' ? (jfGenres.value.data.genres ?? []) : [];
this.userPlaylists.jellyfin =
jfUserPL.status === 'fulfilled' ? (jfUserPL.value.data.playlists ?? []) : [];
this.recommendPlaylists.netease =
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
this.dailySongs.netease =
neDaily.status === 'fulfilled' ? (neDaily.value.data.songs ?? []) : [];
this.userPlaylists.netease =
neUserPL.status === 'fulfilled' ? (neUserPL.value.data.playlists ?? []) : [];
this.recommendPlaylists.qq =
qqRecPL.status === 'fulfilled' ? (qqRecPL.value.data.playlists ?? []) : [];
this.dailySongs.qq =
qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : [];
this.userPlaylists.qq =
qqUserPL.status === 'fulfilled' ? (qqUserPL.value.data.playlists ?? []) : [];
this.recommendPlaylists.kugou =
kgRecPL.status === 'fulfilled' ? (kgRecPL.value.data.playlists ?? []) : [];
this.dailySongs.kugou =
kgDaily.status === 'fulfilled' ? (kgDaily.value.data.songs ?? []) : [];
this.userPlaylists.kugou =
kgUserPL.status === 'fulfilled' ? (kgUserPL.value.data.playlists ?? []) : [];
// bilibili popular: keep previous value on failure (it's an anonymous endpoint
// unrelated to user auth state, and stale popular results are harmless)
if (bili.status === 'fulfilled') {
this.bilibiliPopular = bili.value.data.songs ?? [];
}
// Only mark as fetched if at least the auth-status calls succeeded —
// a fully failed fetch (network blip / server down) should NOT be
// cached for 5 minutes, otherwise the user has to hard-reload to
// recover when connectivity returns. (Disabled providers' checks are
// pre-rejected sentinels, so only enabled ones can vouch here.)
const authOk =
jfAuthRes.status === 'fulfilled' ||
neAuthRes.status === 'fulfilled' ||
qqAuthRes.status === 'fulfilled' ||
kugouAuthRes.status === 'fulfilled';
if (authOk) {
this.lastFetchTime = Date.now();
}
},
/**
* Play a whole Jellyfin genre: fetch its tracks, start the first, queue
* the rest. There is no bulk-queue endpoint, so the remainder is appended
* sequentially — capped at 30 tracks to keep the request burst small.
*/
async playJellyfinGenre(genreId: string) {
if (!this.activeBotId) return;
try {
const res = await axios.get(`/api/music/jellyfin/genre/${genreId}/songs`, {
params: { limit: 30 },
});
const songs: Song[] = res.data.songs ?? [];
if (songs.length === 0) {
this.notify('该流派下没有曲目', 'info');
return;
}
await this.playSong(songs[0]);
for (const song of songs.slice(1)) {
await this.addSong(song);
}
this.notify(`已加入 ${songs.length} 首`, 'info');
this.fetchQueue();
} catch {
this.notify('播放流派失败', 'error');
}
},
},
});