From f9caea6c796c4573de2e73d9363ac6343625ee68 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Tue, 30 Jun 2026 21:11:45 +0800 Subject: [PATCH] feat(kugou): add login-gated discovery (daily/recommend/user playlists, FM) + covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/music/kugou.test.ts | 97 +++++++++++++- src/music/kugou.ts | 206 ++++++++++++++++++++++++++++-- web/src/components/SourceTabs.vue | 1 + web/src/stores/player.ts | 47 +++++-- web/src/stores/sourceTabs.ts | 2 +- web/src/views/Home.vue | 21 ++- web/src/views/Library.vue | 2 +- 7 files changed, 352 insertions(+), 24 deletions(-) diff --git a/src/music/kugou.test.ts b/src/music/kugou.test.ts index 3680787..d421837 100644 --- a/src/music/kugou.test.ts +++ b/src/music/kugou.test.ts @@ -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([]); + }); +}); diff --git a/src/music/kugou.ts b/src/music/kugou.ts index da05986..12db94f 100644 --- a/src/music/kugou.ts +++ b/src/music/kugou.ts @@ -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 { + 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,10 +775,50 @@ 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 { - return []; + 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 (专辑) ---------------------------------------------------------- @@ -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 { + 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 { + 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(); + 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 { const data = await this.request({ diff --git a/web/src/components/SourceTabs.vue b/web/src/components/SourceTabs.vue index b065199..ef4b02d 100644 --- a/web/src/components/SourceTabs.vue +++ b/web/src/components/SourceTabs.vue @@ -24,6 +24,7 @@ import type { Source } from '../stores/player.js'; const LABELS: Record = { netease: '网易云', qq: 'QQ', + kugou: '酷狗', }; defineProps<{ diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 8ac781f..6abc868 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -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(); } diff --git a/web/src/stores/sourceTabs.ts b/web/src/stores/sourceTabs.ts index 8e03986..f3c7472 100644 --- a/web/src/stores/sourceTabs.ts +++ b/web/src/stores/sourceTabs.ts @@ -28,7 +28,7 @@ function readAll(): Partial> { 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 { diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index 07ea9cd..9c107a3 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -41,13 +41,23 @@ +
+
+ +
+
+
酷狗私人电台
+
个性化推荐歌曲流
+
+ +

每日推荐 - +

推荐歌单 - +

我的歌单 {{ currentUserPlaylists.length }} - +
(() => { const s: Source[] = ['netease']; if (store.authStatus.qq) s.push('qq'); + if (store.authStatus.kugou) s.push('kugou'); return s; }); const dailyAvailable = computed(() => 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 { diff --git a/web/src/views/Library.vue b/web/src/views/Library.vue index 91da188..1616e70 100644 --- a/web/src/views/Library.vue +++ b/web/src/views/Library.vue @@ -28,7 +28,7 @@

我的歌单 {{ currentUserPlaylists.length }} - +