mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42: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
+351
-23
No files matched your search
+96
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, krcToLrc } from "./kugou.js";
|
||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js";
|
||||
import { parseLyrics } from "./netease.js";
|
||||
|
||||
describe("mapKugouSongs", () => {
|
||||
@@ -98,3 +98,98 @@ describe("mapKugouAlbums", () => {
|
||||
expect(album.songCount).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouSong — playlist (get_other_list_file) shape", () => {
|
||||
it("splits the combined `name` and uses `mixsongid` as the audio id", () => {
|
||||
// Shape captured live from /pubsongs/v2/get_other_list_file_nofilt (我喜欢).
|
||||
// The combined "歌手 - 歌名" is in `name`; there is no separate songname.
|
||||
const raw = {
|
||||
name: "KOKIA - ありがとう… (谢谢…)",
|
||||
hash: "E9A08A98614DD992F11A68A5E5F1C79F",
|
||||
album_id: "1491689",
|
||||
mixsongid: 37533796,
|
||||
timelen: 248528, // ms
|
||||
cover: "http://imge.kugou.com/stdmusic/{size}/20210113/x.jpg",
|
||||
};
|
||||
const song = mapKugouSong(raw);
|
||||
expect(song.artist).toBe("KOKIA");
|
||||
expect(song.name).toBe("ありがとう… (谢谢…)");
|
||||
// hash lowercased | mixsongid | album_id — so getSongUrl can resolve it.
|
||||
expect(song.id).toBe("e9a08a98614dd992f11a68a5e5f1c79f|37533796|1491689");
|
||||
expect(song.duration).toBe(249); // timelen ms → s
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/20210113/x.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouSong — cover art per endpoint", () => {
|
||||
it("reads `sizable_cover` (daily/FM shape) and resolves {size}→240, http→https", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
sizable_cover: "http://imge.kugou.com/stdmusic/{size}/y.jpg",
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/y.jpg");
|
||||
});
|
||||
|
||||
it("falls back to `trans_param.union_cover` (search shape, no top-level cover)", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
|
||||
});
|
||||
|
||||
it("returns an empty coverUrl when no cover field is present", () => {
|
||||
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 200 }).coverUrl).toBe("");
|
||||
});
|
||||
|
||||
it("skips an empty-string cover field (Kugou returns '') and uses the next non-empty source", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
sizable_cover: "", // present but empty — must NOT mask the real cover below
|
||||
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouPlaylists", () => {
|
||||
it("maps the user-playlist shape (/v7/get_all_list info), keying id on global_collection_id", () => {
|
||||
// Shape captured live from /v7/get_all_list (我喜欢).
|
||||
const [pl] = mapKugouPlaylists([
|
||||
{ name: "我喜欢", count: 7, global_collection_id: "collection_3_2526507197_2_0", listid: 2, type: 0 },
|
||||
]);
|
||||
expect(pl.platform).toBe("kugou");
|
||||
expect(pl.name).toBe("我喜欢");
|
||||
expect(pl.songCount).toBe(7);
|
||||
// Must be the global_collection_id — the only id getPlaylistSongs can open.
|
||||
expect(pl.id).toBe("collection_3_2526507197_2_0");
|
||||
});
|
||||
|
||||
it("maps the recommend shape (/v2/special_recommend), preferring global_collection_id over specialid", () => {
|
||||
// Shape captured live from /v2/special_recommend special_list.
|
||||
const pl = mapKugouPlaylist({
|
||||
specialname: "米津玄师:蒙着眼睛也能炸翻全场",
|
||||
specialid: 1154672,
|
||||
global_collection_id: "collection_1_1029965246_1154672_0",
|
||||
pic: "http://imge.kugou.com/specialimg/{size}/a.jpg",
|
||||
percount: 0,
|
||||
});
|
||||
expect(pl.id).toBe("collection_1_1029965246_1154672_0");
|
||||
expect(pl.name).toBe("米津玄师:蒙着眼睛也能炸翻全场");
|
||||
expect(pl.coverUrl).toBe("https://imge.kugou.com/specialimg/240/a.jpg");
|
||||
});
|
||||
|
||||
it("drops entries whose only id is a non-openable specialid/listid (not a global_collection_id)", () => {
|
||||
// getPlaylistSongs can only open a global_collection_id, so a numeric
|
||||
// specialid/listid would be a dead id — such entries must be dropped.
|
||||
expect(mapKugouPlaylists([{ specialname: "x", specialid: 1154672, listid: 9 }])).toHaveLength(0);
|
||||
expect(mapKugouPlaylists([{ name: "no id" }])).toHaveLength(0);
|
||||
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
+196
-8
@@ -234,6 +234,19 @@ interface KugouRawSong {
|
||||
duration?: number;
|
||||
Duration?: number;
|
||||
timelen?: number;
|
||||
// Playlist (/pubsongs/.../get_other_list_file) shape: a single combined
|
||||
// "歌手 - 歌名" lives in `name`, with `mixsongid`/`audio_id` as the audio id.
|
||||
name?: string;
|
||||
mixsongid?: number | string;
|
||||
audio_id?: number | string;
|
||||
albuminfo?: { name?: string };
|
||||
// Cover art lives in different fields per endpoint: `sizable_cover` (daily/FM,
|
||||
// a `{size}` template), `cover` (playlist), or `trans_param.union_cover`
|
||||
// (search). All may carry a `{size}` placeholder that fixCover() resolves.
|
||||
sizable_cover?: string;
|
||||
album_sizable_cover?: string;
|
||||
cover?: string;
|
||||
trans_param?: { union_cover?: string };
|
||||
}
|
||||
|
||||
/** Split a Kugou "歌手 - 歌名" filename into {artist,name} when needed. */
|
||||
@@ -255,11 +268,15 @@ export function mapKugouSong(raw: KugouNestedTrack): Song {
|
||||
const audioInfo = raw.audio_info ?? {};
|
||||
// Handle both the flat search shape and the nested album/playlist/FM shape.
|
||||
const hash = String(raw.hash ?? raw.FileHash ?? audioInfo.hash ?? "").toLowerCase();
|
||||
const albumAudioId = raw.album_audio_id ?? raw.MixSongID ?? base.album_audio_id ?? 0;
|
||||
const albumAudioId = raw.album_audio_id ?? raw.MixSongID ?? raw.mixsongid ?? raw.audio_id ?? base.album_audio_id ?? 0;
|
||||
const albumId = raw.album_id ?? raw.AlbumID ?? base.album_id ?? 0;
|
||||
const fallback = splitFilename(raw.filename ?? raw.FileName ?? "");
|
||||
const name = (raw.songname ?? raw.SongName ?? base.audio_name ?? fallback.name ?? "").toString().trim();
|
||||
const artist = (raw.singername ?? raw.SingerName ?? base.author_name ?? fallback.artist ?? "").toString().trim();
|
||||
// The playlist endpoint packs "歌手 - 歌名" into `name` (no separate
|
||||
// songname/singername), so treat it like `filename` and split it.
|
||||
const fallback = splitFilename(raw.filename ?? raw.FileName ?? raw.name ?? "");
|
||||
// Use firstStr (not `??`) so an empty-string field doesn't mask a real value
|
||||
// in a later one.
|
||||
const name = firstStr(raw.songname, raw.SongName, base.audio_name, fallback.name).trim();
|
||||
const artist = firstStr(raw.singername, raw.SingerName, base.author_name, fallback.artist).trim();
|
||||
// Determine duration by SOURCE, not magnitude: the search endpoint reports
|
||||
// `duration` in SECONDS, while the nested album/playlist `audio_info` reports
|
||||
// milliseconds (a magnitude heuristic would mis-handle multi-hour tracks).
|
||||
@@ -269,13 +286,19 @@ export function mapKugouSong(raw: KugouNestedTrack): Song {
|
||||
else if (raw.timelen != null) duration = Math.round(Number(raw.timelen) / 1000); // ms
|
||||
else duration = Number(raw.duration ?? raw.Duration ?? 0) || 0; // seconds
|
||||
if (!Number.isFinite(duration) || duration < 0) duration = 0;
|
||||
// Cover art is endpoint-specific: `sizable_cover` (daily/FM), `cover`
|
||||
// (playlist), or `trans_param.union_cover` (search). firstStr skips empty
|
||||
// fields; fixCover resolves the `{size}` template + upgrades http→https.
|
||||
const coverUrl = fixCover(
|
||||
firstStr(raw.sizable_cover, raw.album_sizable_cover, raw.cover, raw.trans_param?.union_cover)
|
||||
);
|
||||
return {
|
||||
id: makeId(hash, albumAudioId, albumId),
|
||||
name: name || "未知歌曲",
|
||||
artist: artist || "未知歌手",
|
||||
album: (raw.album_name ?? raw.AlbumName ?? base.album_name ?? raw.album_info?.album_name ?? "").toString(),
|
||||
album: firstStr(raw.album_name, raw.AlbumName, base.album_name, raw.album_info?.album_name, raw.albuminfo?.name),
|
||||
duration,
|
||||
coverUrl: "",
|
||||
coverUrl,
|
||||
platform: "kugou",
|
||||
};
|
||||
}
|
||||
@@ -307,6 +330,18 @@ function fixCover(url: string | undefined): string {
|
||||
return url.replace(/\{size\}/g, "240").replace(/^http:/, "https:");
|
||||
}
|
||||
|
||||
/** First non-empty string among the candidates. Unlike `??`, an empty string —
|
||||
* which Kugou returns for absent cover/name fields — is treated as missing, so
|
||||
* a real value in a later field isn't masked by an earlier `""`. */
|
||||
function firstStr(...vals: Array<string | number | undefined | null>): string {
|
||||
for (const v of vals) {
|
||||
if (v == null) continue;
|
||||
const s = String(v);
|
||||
if (s !== "") return s;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function mapKugouAlbum(raw: KugouRawAlbum): Album {
|
||||
return {
|
||||
id: String(raw.albumid ?? raw.album_id ?? raw.AlbumID ?? ""),
|
||||
@@ -323,6 +358,57 @@ export function mapKugouAlbums(list: KugouRawAlbum[] | undefined): Album[] {
|
||||
return list.map(mapKugouAlbum);
|
||||
}
|
||||
|
||||
// Playlist shapes differ by endpoint (user lists vs special_recommend), so map
|
||||
// defensively. The id MUST be the `global_collection_id` because that is the
|
||||
// only key getPlaylistSongs()/getPlaylistDetail() can open a playlist by.
|
||||
interface KugouRawPlaylist {
|
||||
global_collection_id?: string;
|
||||
gid?: string | number;
|
||||
specialid?: string | number;
|
||||
listid?: string | number;
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
specialname?: string;
|
||||
list_name?: string;
|
||||
pic?: string;
|
||||
imgurl?: string;
|
||||
flexible_cover?: string;
|
||||
cover?: string;
|
||||
img?: string;
|
||||
count?: number | string;
|
||||
songcount?: number | string;
|
||||
song_count?: number | string;
|
||||
total?: number | string;
|
||||
// Present in real payloads but not used for mapping (kept for shape fidelity):
|
||||
// `percount` is special_recommend's (unreliable, often 0) song count; `type`
|
||||
// is the list kind in get_all_list.
|
||||
percount?: number | string;
|
||||
type?: number | string;
|
||||
}
|
||||
|
||||
export function mapKugouPlaylist(raw: KugouRawPlaylist): Playlist {
|
||||
// The id MUST be the `global_collection_id` (the `collection_*` form) — it is
|
||||
// the ONLY key getPlaylistSongs()/getPlaylistDetail() can open a playlist by.
|
||||
// A numeric `specialid`/`listid`/`id` is NOT openable, so it must NOT become
|
||||
// the id (mapKugouPlaylists drops entries that resolve to ""). `gid` is the
|
||||
// common alias for the same collection key in some list shapes.
|
||||
const id = String(raw.global_collection_id ?? raw.gid ?? "");
|
||||
const name = firstStr(raw.name, raw.specialname, raw.list_name).trim();
|
||||
return {
|
||||
id,
|
||||
name: name || "未知歌单",
|
||||
coverUrl: fixCover(firstStr(raw.pic, raw.imgurl, raw.flexible_cover, raw.cover, raw.img)),
|
||||
songCount: Number(raw.count ?? raw.songcount ?? raw.song_count ?? raw.total ?? 0) || 0,
|
||||
platform: "kugou",
|
||||
};
|
||||
}
|
||||
|
||||
export function mapKugouPlaylists(list: KugouRawPlaylist[] | undefined): Playlist[] {
|
||||
if (!Array.isArray(list)) return [];
|
||||
// Drop entries with no resolvable global_collection_id (can't be opened).
|
||||
return list.map(mapKugouPlaylist).filter((p) => p.id !== "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -689,11 +775,51 @@ export class KugouProvider implements MusicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Recommend playlists need a logged-in/personalized context that can't be
|
||||
// reliably resolved anonymously; return empty rather than ship a wrong guess.
|
||||
// 推荐歌单 (精选/个性化歌单). Ported from reference top_playlist.js
|
||||
// (/v2/special_recommend). The misspelled keys `retrun_min` /
|
||||
// `return_special_falg` are intentional — the server expects those exact names.
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
try {
|
||||
const dateTime = Math.floor(Date.now() / 1000).toString();
|
||||
const data = await this.request({
|
||||
method: "POST",
|
||||
url: "/v2/special_recommend",
|
||||
xRouter: "specialrec.service.kugou.com",
|
||||
data: {
|
||||
appid: APPID,
|
||||
mid: this.mid,
|
||||
clientver: CLIENTVER,
|
||||
platform: "android",
|
||||
clienttime: dateTime,
|
||||
userid: this.cookie.userid || 0,
|
||||
module_id: 1,
|
||||
page: 1,
|
||||
pagesize: 30,
|
||||
key: signParamsKey(dateTime),
|
||||
special_recommend: {
|
||||
withtag: 1,
|
||||
withsong: 1,
|
||||
sort: 1,
|
||||
ugc: 1,
|
||||
is_selected: 0,
|
||||
withrecommend: 1,
|
||||
area_code: 1,
|
||||
categoryid: 0,
|
||||
},
|
||||
req_multi: 1,
|
||||
retrun_min: 5,
|
||||
return_special_falg: 1,
|
||||
},
|
||||
});
|
||||
// The list is nested under one of several shapes across API versions.
|
||||
const d = data?.data ?? {};
|
||||
const list =
|
||||
d.special_list ?? d.info ?? d.list ?? d.special ?? (Array.isArray(d) ? d : []);
|
||||
return mapKugouPlaylists(list as KugouRawPlaylist[]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album (专辑) ----------------------------------------------------------
|
||||
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||
@@ -768,6 +894,68 @@ export class KugouProvider implements MusicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 每日推荐歌曲 (daily recommend) ---------------------------------------
|
||||
// Ported from reference recommend_songs.js (/everyday_song_recommend).
|
||||
// Requires a logged-in cookie (userid/token) for personalised results.
|
||||
async getDailyRecommendSongs(): Promise<Song[]> {
|
||||
try {
|
||||
const userid = this.cookie.userid && this.cookie.userid !== "0" ? this.cookie.userid : "0";
|
||||
const data = await this.request({
|
||||
method: "POST",
|
||||
url: "/everyday_song_recommend",
|
||||
xRouter: "everydayrec.service.kugou.com",
|
||||
encryptType: "android",
|
||||
data: { platform: "android", userid },
|
||||
});
|
||||
const d = data?.data ?? {};
|
||||
const list = d.song_list ?? d.songs ?? d.list ?? d.info ?? [];
|
||||
return mapKugouSongs(list as KugouNestedTrack[]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- 用户歌单 (the logged-in user's own playlists) ------------------------
|
||||
// Ported from reference user_playlist.js (/v7/get_all_list).
|
||||
async getUserPlaylists(): Promise<Playlist[]> {
|
||||
try {
|
||||
const userid = this.cookie.userid || "0";
|
||||
const token = this.cookie.token || "";
|
||||
if (!userid || userid === "0" || !token) return [];
|
||||
const out: Playlist[] = [];
|
||||
const seen = new Set<string>();
|
||||
const PAGE_SIZE = 30;
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const data = await this.request({
|
||||
method: "POST",
|
||||
url: "/v7/get_all_list",
|
||||
xRouter: "cloudlist.service.kugou.com",
|
||||
encryptType: "android",
|
||||
params: { plat: 1, userid: Number(userid), token },
|
||||
data: { userid: Number(userid), token, total_ver: 979, type: 2, page, pagesize: PAGE_SIZE },
|
||||
});
|
||||
const d = data?.data ?? {};
|
||||
const list = (d.info ?? d.list ?? []) as KugouRawPlaylist[];
|
||||
// Dedup by id: some Kugou list endpoints ignore `page`/`pagesize` and
|
||||
// return the whole list every time, which would otherwise duplicate
|
||||
// entries 10× for users with ≥30 playlists.
|
||||
let added = 0;
|
||||
for (const pl of mapKugouPlaylists(list)) {
|
||||
if (seen.has(pl.id)) continue;
|
||||
seen.add(pl.id);
|
||||
out.push(pl);
|
||||
added++;
|
||||
}
|
||||
// Stop on a short page (real pagination) OR when a page contributed
|
||||
// nothing new (the endpoint re-returned an already-seen set).
|
||||
if (list.length < PAGE_SIZE || added === 0) break;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- QR login --------------------------------------------------------------
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
const data = await this.request({
|
||||
|
||||
@@ -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