mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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>
196 lines
8.2 KiB
TypeScript
196 lines
8.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js";
|
|
import { parseLyrics } from "./netease.js";
|
|
|
|
describe("mapKugouSongs", () => {
|
|
it("maps the mobile-search song shape to a Song with platform 'kugou'", () => {
|
|
// Shape captured live from mobilecdn.kugou.com/api/v3/search/song.
|
|
const raw = {
|
|
hash: "B3A52A7A958BF0AED0EBFBA2E9A818B7",
|
|
album_audio_id: 32100650,
|
|
album_id: "966846",
|
|
songname: "晴天",
|
|
singername: "周杰伦",
|
|
duration: 269,
|
|
};
|
|
const [song] = mapKugouSongs([raw]);
|
|
expect(song.platform).toBe("kugou");
|
|
expect(song.name).toBe("晴天");
|
|
expect(song.artist).toBe("周杰伦");
|
|
expect(song.duration).toBe(269);
|
|
// The id must round-trip hash + album_audio_id + album_id so getSongUrl
|
|
// can resolve a stream from a search result.
|
|
expect(song.id).toBe("b3a52a7a958bf0aed0ebfba2e9a818b7|32100650|966846");
|
|
});
|
|
|
|
it("treats nested audio_info durations as milliseconds and flat search durations as seconds", () => {
|
|
// List endpoints: ms under audio_info.
|
|
expect(mapKugouSong({ audio_info: { hash: "abc", duration: 215000 } }).duration).toBe(215);
|
|
// Search endpoint: a long-form track's flat `duration` stays seconds (no /1000).
|
|
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 10800 }).duration).toBe(10800);
|
|
});
|
|
|
|
it("falls back to splitting '歌手 - 歌名' from the filename", () => {
|
|
const song = mapKugouSong({ FileHash: "deadbeef", filename: "周杰伦 - 稻香", Duration: 200 });
|
|
expect(song.artist).toBe("周杰伦");
|
|
expect(song.name).toBe("稻香");
|
|
});
|
|
|
|
it("uses PascalCase fields from the gateway shape and skips entries with no hash", () => {
|
|
const songs = mapKugouSongs([
|
|
{ FileHash: "aa", SongName: "n", SingerName: "s", MixSongID: 1, AlbumID: 2 },
|
|
{ songname: "no hash" } as any,
|
|
]);
|
|
expect(songs).toHaveLength(1);
|
|
expect(songs[0].id).toBe("aa|1|2");
|
|
});
|
|
|
|
it("returns [] for non-array input", () => {
|
|
expect(mapKugouSongs(undefined)).toEqual([]);
|
|
});
|
|
|
|
it("maps the NESTED album/playlist track shape (base + audio_info)", () => {
|
|
// Shape captured live from /album/songs (叶惠美).
|
|
const raw = {
|
|
base: { album_id: 966846, album_audio_id: 32100648, audio_name: "以父之名", author_name: "周杰伦" },
|
|
audio_info: { hash: "DBC0207490EB51153EF933EF5A7E98E4", duration: 342047 },
|
|
};
|
|
const [song] = mapKugouSongs([raw]);
|
|
expect(song.name).toBe("以父之名");
|
|
expect(song.artist).toBe("周杰伦");
|
|
expect(song.duration).toBe(342); // ms → s
|
|
expect(song.id).toBe("dbc0207490eb51153ef933ef5a7e98e4|32100648|966846");
|
|
});
|
|
});
|
|
|
|
describe("krcToLrc", () => {
|
|
it("converts KRC [startMs,durMs] line timestamps + strips word timings into parseable LRC", () => {
|
|
// Shape captured live from lyrics.kugou.com (周杰伦 - 晴天).
|
|
const krc = [
|
|
"[ti:晴天]",
|
|
"[ar:周杰伦]",
|
|
"[0,2250]<0,160,0>晴<160,160,0>天",
|
|
"[63000,1800]<0,200,0>故<200,200,0>事",
|
|
].join("\n");
|
|
const lrc = krcToLrc(krc);
|
|
expect(lrc).toContain("[00:00.00]晴天");
|
|
expect(lrc).toContain("[01:03.00]故事");
|
|
expect(lrc).not.toMatch(/<\d+,\d+,\d+>/); // word timings stripped
|
|
|
|
// And the shared LRC parser must now actually produce timed lines.
|
|
const lines = parseLyrics(lrc);
|
|
expect(lines.length).toBe(2);
|
|
expect(lines[0]).toEqual({ time: 0, text: "晴天" });
|
|
expect(lines[1].time).toBe(63);
|
|
expect(lines[1].text).toBe("故事");
|
|
});
|
|
});
|
|
|
|
describe("mapKugouAlbums", () => {
|
|
it("maps album shape and normalises {size} covers to https", () => {
|
|
const [album] = mapKugouAlbums([
|
|
{ album_id: 966846, album_name: "叶惠美", singername: "周杰伦", sizable_cover: "http://imge.kugou.com/x/{size}/abc.jpg", songcount: 11 },
|
|
]);
|
|
expect(album.platform).toBe("kugou");
|
|
expect(album.id).toBe("966846");
|
|
expect(album.name).toBe("叶惠美");
|
|
expect(album.coverUrl).toBe("https://imge.kugou.com/x/240/abc.jpg");
|
|
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([]);
|
|
});
|
|
});
|