mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(kugou): add login-gated discovery (daily/recommend/user playlists, FM) + covers
Implements the NetEase-parity login features for the Kugou provider now that
QR login works:
- getDailyRecommendSongs (每日推荐), getUserPlaylists (我的歌单), and a real
getRecommendPlaylists (推荐歌单, was a stub) ported from the reference API.
- mapKugouSong now extracts cover art per endpoint (sizable_cover / cover /
trans_param.union_cover, resolving the {size} template) — Kugou songs had no
artwork before.
- Fix the playlist-song shape (combined "歌手 - 歌名" in `name`, mixsongid as the
audio id) so opened playlists show real titles instead of 未知歌曲.
- New defensive playlist mappers keyed on global_collection_id (the only id
getPlaylistSongs can open); dedup user playlists in case the list endpoint
ignores pagination; firstStr() so an empty-string field can't mask a real one.
Frontend wires Kugou as a third home-discovery source (Source type, store
caches/auth, availableSources, fetchHomeData, Home FM card + source tabs,
SourceTabs label, persisted-tab whitelist). SourceTabs now highlights the
fallback-corrected source so the active tab shows when a logged-out source was
persisted (newly possible with 3 sources).
Adds kugou.test.ts coverage for the new mappers, the cover/empty-string and
playlist-shape handling, and id openability.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a12c419dd2
commit
f9caea6c79
7 files changed
+352
-24
No files matched your search
@@ -24,6 +24,7 @@ import type { Source } from '../stores/player.js';
|
||||
const LABELS: Record<Source, string> = {
|
||||
netease: '网易云',
|
||||
qq: 'QQ',
|
||||
kugou: '酷狗',
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface Song {
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou';
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq';
|
||||
export type Source = 'netease' | 'qq' | 'kugou';
|
||||
|
||||
export interface BotStatus {
|
||||
id: string;
|
||||
@@ -100,11 +100,11 @@ export const usePlayerStore = defineStore('player', {
|
||||
theme: 'dark' as 'dark' | 'light',
|
||||
|
||||
// Home page cache, split by source
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
|
||||
bilibiliPopular: [] as Song[],
|
||||
authStatus: { netease: false, qq: false },
|
||||
authStatus: { netease: false, qq: false, kugou: false },
|
||||
lastFetchTime: 0,
|
||||
|
||||
// Favorited playlists (fetched from server, isolated per WebUI user)
|
||||
@@ -157,6 +157,7 @@ export const usePlayerStore = defineStore('player', {
|
||||
const s: Source[] = [];
|
||||
if (this.authStatus.netease) s.push('netease');
|
||||
if (this.authStatus.qq) s.push('qq');
|
||||
if (this.authStatus.kugou) s.push('kugou');
|
||||
return s;
|
||||
},
|
||||
},
|
||||
@@ -571,18 +572,23 @@ export const usePlayerStore = defineStore('player', {
|
||||
// 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 [neAuthRes, qqAuthRes] = await Promise.allSettled([
|
||||
const [neAuthRes, qqAuthRes, kugouAuthRes] = await Promise.allSettled([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
]);
|
||||
const newAuth = {
|
||||
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
|
||||
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
|
||||
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
|
||||
};
|
||||
const authChanged =
|
||||
newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq;
|
||||
newAuth.netease !== this.authStatus.netease ||
|
||||
newAuth.qq !== this.authStatus.qq ||
|
||||
newAuth.kugou !== this.authStatus.kugou;
|
||||
this.authStatus.netease = newAuth.netease;
|
||||
this.authStatus.qq = newAuth.qq;
|
||||
this.authStatus.kugou = newAuth.kugou;
|
||||
|
||||
// Favorites are user-local and cheap; always refresh them, even on a
|
||||
// home-data cache hit, so hearts stay correct across tabs/sessions.
|
||||
@@ -621,15 +627,30 @@ export const usePlayerStore = defineStore('player', {
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
// 4. Kugou data: every section (incl. recommend playlists) needs login,
|
||||
// so gate all three on auth like QQ.
|
||||
const kugouPromises = 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 = axios.get('/api/music/bilibili/popular?limit=12');
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
...neteasePromises,
|
||||
...qqPromises,
|
||||
...kugouPromises,
|
||||
biliPromise,
|
||||
]);
|
||||
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili] = results;
|
||||
|
||||
this.recommendPlaylists.netease =
|
||||
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
|
||||
@@ -643,6 +664,12 @@ export const usePlayerStore = defineStore('player', {
|
||||
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') {
|
||||
@@ -654,7 +681,9 @@ export const usePlayerStore = defineStore('player', {
|
||||
// cached for 5 minutes, otherwise the user has to hard-reload to
|
||||
// recover when connectivity returns.
|
||||
const authOk =
|
||||
neAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled';
|
||||
neAuthRes.status === 'fulfilled' ||
|
||||
qqAuthRes.status === 'fulfilled' ||
|
||||
kugouAuthRes.status === 'fulfilled';
|
||||
if (authOk) {
|
||||
this.lastFetchTime = Date.now();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
|
||||
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
|
||||
const all = readAll();
|
||||
const v = all[key];
|
||||
return v === 'netease' || v === 'qq' ? v : fallback;
|
||||
return v === 'netease' || v === 'qq' || v === 'kugou' ? v : fallback;
|
||||
}
|
||||
|
||||
export function saveTabSource(key: TabKey, value: Source): void {
|
||||
|
||||
+18
-3
@@ -41,13 +41,23 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||
<div class="fm-icon-wrapper kugou">
|
||||
<Icon icon="mdi:radio-tower" class="fm-icon" />
|
||||
</div>
|
||||
<div class="fm-info">
|
||||
<div class="fm-title">酷狗私人电台</div>
|
||||
<div class="fm-desc">个性化推荐歌曲流</div>
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="dailyAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
每日推荐
|
||||
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
|
||||
<SourceTabs :model-value="dailySourceSafe" @update:model-value="dailySource = $event" :sources="dailyAvailable" />
|
||||
</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
@@ -67,7 +77,7 @@
|
||||
<section class="section" v-if="recommendAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
推荐歌单
|
||||
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
|
||||
<SourceTabs :model-value="recommendSourceSafe" @update:model-value="recommendSource = $event" :sources="recommendAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
@@ -108,7 +118,7 @@
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
@@ -173,6 +183,7 @@ const userPlaylistsExpanded = ref(false);
|
||||
const recommendAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = ['netease'];
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
if (store.authStatus.kugou) s.push('kugou');
|
||||
return s;
|
||||
});
|
||||
const dailyAvailable = computed<Source[]>(() => store.availableSources);
|
||||
@@ -358,6 +369,10 @@ onMounted(() => {
|
||||
&.qq {
|
||||
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
|
||||
}
|
||||
|
||||
&.kugou {
|
||||
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-icon {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
|
||||
Reference in new issue
Block a user