mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(music): add Kugou (酷狗音乐) as a music source (#69)
Adds a self-contained Kugou provider (bilibili-style: direct API calls, no embedded API server, no new npm dependency) plus full backend + WebUI wiring. Provider (src/music/kugou.ts): search, song-url (with device registration), lyrics (KRC decode), song detail, playlist, album, personal FM, QR login + cookie persistence, and quality. Request signing / crypto / KRC decoding are ported from the MIT-licensed MakcRe/KuGouMusicApi using Node's built-in crypto and zlib (no third-party crypto packages). Wiring: the "kugou" platform is threaded through the provider contract, queue, play-history, bot instance/manager dispatch (getProviderFor + the -k command flag), index/server composition, the music/player/auth routers (unified /search/all, /quality, the platform coercions, QR login), the cookie store, and the WebUI (search source tab + badge, SongCard badge, brand token, and a Kugou QR/cookie login card in Settings). Verified live during development: search, lyrics, and album playback resolve correctly. NOT verifiable in CI (Kugou anti-bot blocks the build host's IP): play-URL resolution, QR login, and VIP audio — these are built faithfully to the reference and need end-to-end testing on a non-flagged IP / a Kugou account. See the header comment in kugou.ts. Includes src/music/kugou.test.ts (mappers + KRC→LRC). An adversarial review pass fixed: pagination truncating on filtered counts, an ms/seconds duration heuristic, dfid soft-fail caching, the /v5/url random-dfid fallback, the FM body identity, and an unguarded nickname decode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
af326a37b7
commit
a12c419dd2
18 files changed
+1109
-32
No files matched your search
+1
-1
@@ -10,7 +10,7 @@ export interface QueuedSong {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
|
||||
+6
-1
@@ -36,6 +36,7 @@ export interface BotInstanceOptions {
|
||||
bilibiliProvider: MusicProvider;
|
||||
youtubeProvider: MusicProvider;
|
||||
localProvider?: MusicProvider;
|
||||
kugouProvider?: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
@@ -69,6 +70,7 @@ export class BotInstance extends EventEmitter {
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -98,6 +100,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.bilibiliProvider = options.bilibiliProvider;
|
||||
this.youtubeProvider = options.youtubeProvider;
|
||||
this.localProvider = options.localProvider ?? options.neteaseProvider;
|
||||
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
|
||||
this.database = options.database;
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ botId: this.id });
|
||||
@@ -524,10 +527,11 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local"): MusicProvider {
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"): MusicProvider {
|
||||
if (platform === "bilibili") return this.bilibiliProvider;
|
||||
if (platform === "youtube") return this.youtubeProvider;
|
||||
if (platform === "local") return this.localProvider;
|
||||
if (platform === "kugou") return this.kugouProvider;
|
||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -540,6 +544,7 @@ export class BotInstance extends EventEmitter {
|
||||
if (flags.has("b")) return this.bilibiliProvider;
|
||||
if (flags.has("q")) return this.qqProvider;
|
||||
if (flags.has("y")) return this.youtubeProvider;
|
||||
if (flags.has("k")) return this.kugouProvider;
|
||||
return this.neteaseProvider;
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -75,6 +75,7 @@ export class BotManager extends EventEmitter {
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -92,7 +93,8 @@ export class BotManager extends EventEmitter {
|
||||
avatarStore: AvatarStore,
|
||||
permissions: PermissionStore,
|
||||
configPath: string,
|
||||
localProvider?: MusicProvider
|
||||
localProvider?: MusicProvider,
|
||||
kugouProvider?: MusicProvider
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
@@ -100,6 +102,7 @@ export class BotManager extends EventEmitter {
|
||||
this.bilibiliProvider = bilibiliProvider;
|
||||
this.youtubeProvider = new YouTubeProvider();
|
||||
this.localProvider = localProvider ?? neteaseProvider;
|
||||
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
||||
// Let the local provider see which uploads are still referenced by any
|
||||
// bot's queue, so it never deletes a file another queue/bot still needs.
|
||||
const referenceable = this.localProvider as Partial<{
|
||||
@@ -137,6 +140,7 @@ export class BotManager extends EventEmitter {
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -276,6 +280,7 @@ export class BotManager extends EventEmitter {
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -329,6 +334,7 @@ export class BotManager extends EventEmitter {
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -8,6 +8,7 @@ import { NeteaseProvider } from "./music/netease.js";
|
||||
import { QQMusicProvider } from "./music/qq.js";
|
||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||
import { LocalMusicProvider } from "./music/local.js";
|
||||
import { KugouProvider } from "./music/kugou.js";
|
||||
import { createCookieStore } from "./music/auth.js";
|
||||
import { createAvatarStore } from "./data/avatars.js";
|
||||
import { createPermissionStore } from "./data/permissions.js";
|
||||
@@ -57,6 +58,7 @@ async function main() {
|
||||
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
|
||||
const bilibiliProvider = new BiliBiliProvider();
|
||||
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
|
||||
const kugouProvider = new KugouProvider();
|
||||
|
||||
const cookieStore = createCookieStore(COOKIE_DIR);
|
||||
const avatarStore = createAvatarStore(AVATAR_DIR);
|
||||
@@ -66,6 +68,8 @@ async function main() {
|
||||
if (qqCookie) qqProvider.setCookie(qqCookie);
|
||||
const bilibiliCookie = cookieStore.load("bilibili");
|
||||
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
|
||||
const kugouCookie = cookieStore.load("kugou");
|
||||
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
|
||||
|
||||
const permissions = createPermissionStore(db.db);
|
||||
|
||||
@@ -79,7 +83,8 @@ async function main() {
|
||||
avatarStore,
|
||||
permissions,
|
||||
CONFIG_PATH,
|
||||
localProvider
|
||||
localProvider,
|
||||
kugouProvider
|
||||
);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
@@ -90,6 +95,7 @@ async function main() {
|
||||
qqProvider,
|
||||
bilibiliProvider,
|
||||
localProvider,
|
||||
kugouProvider,
|
||||
database: db,
|
||||
avatarStore,
|
||||
config,
|
||||
|
||||
+4
-4
@@ -2,8 +2,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface CookieStore {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili"): string;
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
|
||||
}
|
||||
|
||||
export function createCookieStore(cookieDir: string): CookieStore {
|
||||
@@ -12,7 +12,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
}
|
||||
|
||||
return {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void {
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
@@ -21,7 +21,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
);
|
||||
},
|
||||
|
||||
load(platform: "netease" | "qq" | "bilibili"): string {
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
if (!fs.existsSync(filePath)) return "";
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,840 @@
|
||||
/**
|
||||
* Kugou Music (酷狗音乐) provider.
|
||||
*
|
||||
* A self-contained provider (bilibili-style: direct API calls, no embedded API
|
||||
* server, no extra npm dependency). The request signing / device registration /
|
||||
* KRC lyric decoding are ported from the MIT-licensed reference implementation
|
||||
* `MakcRe/KuGouMusicApi` (Copyright (c) 2023 MakcRe), reimplemented here with
|
||||
* Node's built-in `crypto` and `zlib` so no third-party crypto packages are
|
||||
* pulled in.
|
||||
*
|
||||
* VERIFICATION NOTE: search and lyrics were verified live during development.
|
||||
* The play-URL endpoint (`/v5/url`) is gated by Kugou's risk-control and may
|
||||
* return `errcode 20028 "本次请求需要验证"` from flagged (datacenter) IPs even
|
||||
* for free songs; on such hosts a logged-in session (QR login below) and/or a
|
||||
* residential IP is required. QR login and VIP audio could not be verified in
|
||||
* the build environment.
|
||||
*/
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
import crypto from "node:crypto";
|
||||
import zlib from "node:zlib";
|
||||
import { parseLyrics } from "./netease.js";
|
||||
import type {
|
||||
Album,
|
||||
AuthStatus,
|
||||
LyricLine,
|
||||
MusicProvider,
|
||||
Playlist,
|
||||
PlaylistDetail,
|
||||
QrCodeResult,
|
||||
SearchResult,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
} from "./provider.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (util/config.json + util/helper.js salts)
|
||||
// ---------------------------------------------------------------------------
|
||||
const APPID = 1005;
|
||||
const CLIENTVER = 20489;
|
||||
const SRCAPPID = 2919;
|
||||
const USER_AGENT = "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi";
|
||||
|
||||
const SALT_ANDROID = "OIlwieks28dk2k092lksi2UIkp";
|
||||
const SALT_WEB = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt";
|
||||
const SALT_SIGNKEY = "57ae12eb6890223e355ccfcb74edf70d";
|
||||
|
||||
// RSA public key used by the reference for /register/dev (`rsaEncrypt2`).
|
||||
const RSA_PUBLIC_KEY =
|
||||
"-----BEGIN PUBLIC KEY-----\n" +
|
||||
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/g" +
|
||||
"bjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1" +
|
||||
"+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6D" +
|
||||
"JE5E221wf/4WLFxwAtRQIDAQAB\n" +
|
||||
"-----END PUBLIC KEY-----";
|
||||
|
||||
// KRC lyric XOR key (util/util.js decodeLyrics).
|
||||
const KRC_KEY = [64, 71, 97, 119, 94, 50, 116, 71, 81, 54, 49, 45, 206, 210, 110, 105];
|
||||
|
||||
const QUALITY_MAP: Record<string, string> = {
|
||||
standard: "128",
|
||||
higher: "320",
|
||||
exhigh: "320",
|
||||
lossless: "flac",
|
||||
hires: "high",
|
||||
"128": "128",
|
||||
"320": "320",
|
||||
flac: "flac",
|
||||
high: "high",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crypto / signing primitives (ported from util/crypto.js + util/helper.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
function md5(input: string | Buffer): string {
|
||||
return crypto.createHash("md5").update(input).digest("hex");
|
||||
}
|
||||
|
||||
function randomString(len = 16): string {
|
||||
const pool = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i++) out += pool[Math.floor(Math.random() * pool.length)];
|
||||
return out;
|
||||
}
|
||||
|
||||
function getGuid(): string {
|
||||
const e = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
|
||||
return `${e()}${e()}-${e()}-${e()}-${e()}-${e()}${e()}${e()}`;
|
||||
}
|
||||
|
||||
/** MID = decimal string of the MD5 hex digest interpreted as a base-16 bigint. */
|
||||
function calculateMid(str: string): string {
|
||||
const digest = md5(str);
|
||||
let acc = 0n;
|
||||
for (const ch of digest) acc = acc * 16n + BigInt(parseInt(ch, 16));
|
||||
return acc.toString();
|
||||
}
|
||||
|
||||
/** Android request signature: md5(salt + sorted(k=v...) + body + salt). */
|
||||
function signatureAndroid(params: Record<string, unknown>, data = ""): string {
|
||||
const s = Object.keys(params)
|
||||
.sort()
|
||||
.map((k) => {
|
||||
const v = params[k];
|
||||
return `${k}=${typeof v === "object" ? JSON.stringify(v) : v}`;
|
||||
})
|
||||
.join("");
|
||||
return md5(`${SALT_ANDROID}${s}${data}${SALT_ANDROID}`);
|
||||
}
|
||||
|
||||
/** Web request signature (QR login): md5(salt + sort("k=v") + salt). */
|
||||
function signatureWeb(params: Record<string, unknown>): string {
|
||||
const s = Object.keys(params)
|
||||
.map((k) => `${k}=${params[k]}`)
|
||||
.sort()
|
||||
.join("");
|
||||
return md5(`${SALT_WEB}${s}${SALT_WEB}`);
|
||||
}
|
||||
|
||||
/** key param for /song/url: md5(hash + salt + appid + mid + userid). */
|
||||
function signKey(hash: string, mid: string, userid: string | number, appid: number): string {
|
||||
return md5(`${hash}${SALT_SIGNKEY}${appid}${mid}${userid || 0}`);
|
||||
}
|
||||
|
||||
/** sign param for personal-fm etc: md5(appid + salt + clientver + data). */
|
||||
function signParamsKey(data: string | number): string {
|
||||
return md5(`${APPID}${SALT_ANDROID}${CLIENTVER}${data}`);
|
||||
}
|
||||
|
||||
/** AES-128-CBC/Pkcs7, key/iv derived from md5 of a random 6-char string. */
|
||||
function playlistAesEncrypt(obj: unknown): { key: string; str: string } {
|
||||
const key = randomString(6).toLowerCase();
|
||||
const digest = md5(key);
|
||||
const encKey = digest.substring(0, 16);
|
||||
const iv = digest.substring(16, 32);
|
||||
const cipher = crypto.createCipheriv("aes-128-cbc", Buffer.from(encKey, "utf8"), Buffer.from(iv, "utf8"));
|
||||
const plain = typeof obj === "object" ? JSON.stringify(obj) : String(obj);
|
||||
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||
return { key, str: enc.toString("base64") };
|
||||
}
|
||||
|
||||
function playlistAesDecrypt(strBase64: string, key: string): unknown {
|
||||
const digest = md5(key);
|
||||
const encKey = digest.substring(0, 16);
|
||||
const iv = digest.substring(16, 32);
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", Buffer.from(encKey, "utf8"), Buffer.from(iv, "utf8"));
|
||||
const dec = Buffer.concat([decipher.update(Buffer.from(strBase64, "base64")), decipher.final()]);
|
||||
const text = dec.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/** RSAES-PKCS1-v1_5 with the bundled public key; hex output (rsaEncrypt2). */
|
||||
function rsaEncrypt2(obj: unknown): string {
|
||||
const plain = Buffer.from(typeof obj === "object" ? JSON.stringify(obj) : String(obj), "utf8");
|
||||
const enc = crypto.publicEncrypt(
|
||||
{ key: RSA_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING },
|
||||
plain,
|
||||
);
|
||||
return enc.toString("hex");
|
||||
}
|
||||
|
||||
/** Decode a base64 KRC lyric blob: drop 4-byte header, XOR, zlib-inflate. */
|
||||
function decodeKrc(base64: string): string {
|
||||
try {
|
||||
const bytes = Buffer.from(base64, "base64").subarray(4);
|
||||
const out = Buffer.alloc(bytes.length);
|
||||
for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] ^ KRC_KEY[i % KRC_KEY.length];
|
||||
return zlib.inflateSync(out).toString("utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decoded KRC lyric to standard LRC so the shared parseLyrics() can
|
||||
* read it. KRC line timestamps are `[startMs,durationMs]` (not `[mm:ss.xx]`)
|
||||
* and each line carries inline `<offset,dur,0>` word timings that must be
|
||||
* stripped. Metadata tags like `[ti:...]` are passed through (parseLyrics
|
||||
* ignores them).
|
||||
*/
|
||||
export function krcToLrc(krc: string): string {
|
||||
return krc
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const m = line.match(/^\[(\d+),(\d+)\](.*)$/);
|
||||
if (m) {
|
||||
const startMs = Number(m[1]);
|
||||
const mm = Math.floor(startMs / 60000);
|
||||
const ss = Math.floor((startMs % 60000) / 1000);
|
||||
const cs = Math.floor((startMs % 1000) / 10);
|
||||
const ts = `[${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}.${String(cs).padStart(2, "0")}]`;
|
||||
return ts + m[3].replace(/<\d+,\d+,\d+>/g, "");
|
||||
}
|
||||
return line.replace(/<\d+,\d+,\d+>/g, "");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite song id: Kugou needs both `hash` and `album_audio_id` (+album_id)
|
||||
// to resolve a stream, so the provider encodes all three into Song.id.
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeId(hash: string, albumAudioId: string | number, albumId: string | number): string {
|
||||
return `${(hash || "").toLowerCase()}|${albumAudioId || 0}|${albumId || 0}`;
|
||||
}
|
||||
function parseId(id: string): { hash: string; albumAudioId: string; albumId: string } {
|
||||
const [hash = "", albumAudioId = "0", albumId = "0"] = (id || "").split("|");
|
||||
return { hash, albumAudioId, albumId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure mappers (exported for unit testing — mirror qq.ts / netease.ts pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface KugouRawSong {
|
||||
hash?: string;
|
||||
FileHash?: string;
|
||||
SQFileHash?: string;
|
||||
sqhash?: string;
|
||||
album_audio_id?: number | string;
|
||||
album_id?: number | string;
|
||||
AlbumID?: number | string;
|
||||
MixSongID?: number | string;
|
||||
songname?: string;
|
||||
SongName?: string;
|
||||
filename?: string;
|
||||
FileName?: string;
|
||||
singername?: string;
|
||||
SingerName?: string;
|
||||
album_name?: string;
|
||||
AlbumName?: string;
|
||||
duration?: number;
|
||||
Duration?: number;
|
||||
timelen?: number;
|
||||
}
|
||||
|
||||
/** Split a Kugou "歌手 - 歌名" filename into {artist,name} when needed. */
|
||||
function splitFilename(filename: string): { name: string; artist: string } {
|
||||
const idx = filename.indexOf(" - ");
|
||||
if (idx < 0) return { name: filename.trim(), artist: "" };
|
||||
return { artist: filename.slice(0, idx).trim(), name: filename.slice(idx + 3).trim() };
|
||||
}
|
||||
|
||||
/** Album/playlist/FM list endpoints nest fields under base/audio_info. */
|
||||
interface KugouNestedTrack extends KugouRawSong {
|
||||
base?: { album_id?: number | string; album_audio_id?: number | string; audio_name?: string; author_name?: string; album_name?: string };
|
||||
audio_info?: { hash?: string; duration?: number; timelength?: number };
|
||||
album_info?: { album_name?: string };
|
||||
}
|
||||
|
||||
export function mapKugouSong(raw: KugouNestedTrack): Song {
|
||||
const base = raw.base ?? {};
|
||||
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 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();
|
||||
// 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).
|
||||
let duration: number;
|
||||
if (audioInfo.duration != null) duration = Math.round(Number(audioInfo.duration) / 1000);
|
||||
else if (audioInfo.timelength != null) duration = Math.round(Number(audioInfo.timelength) / 1000);
|
||||
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;
|
||||
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(),
|
||||
duration,
|
||||
coverUrl: "",
|
||||
platform: "kugou",
|
||||
};
|
||||
}
|
||||
|
||||
export function mapKugouSongs(list: KugouNestedTrack[] | undefined): Song[] {
|
||||
if (!Array.isArray(list)) return [];
|
||||
// Drop entries that yield no hash (the first id segment) regardless of shape.
|
||||
return list.map(mapKugouSong).filter((s) => s.id.split("|")[0] !== "");
|
||||
}
|
||||
|
||||
interface KugouRawAlbum {
|
||||
albumid?: number | string;
|
||||
album_id?: number | string;
|
||||
AlbumID?: number | string;
|
||||
albumname?: string;
|
||||
album_name?: string;
|
||||
AlbumName?: string;
|
||||
singername?: string;
|
||||
SingerName?: string;
|
||||
author_name?: string;
|
||||
imgurl?: string;
|
||||
sizable_cover?: string;
|
||||
songcount?: number;
|
||||
song_count?: number;
|
||||
}
|
||||
|
||||
function fixCover(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
return url.replace(/\{size\}/g, "240").replace(/^http:/, "https:");
|
||||
}
|
||||
|
||||
export function mapKugouAlbum(raw: KugouRawAlbum): Album {
|
||||
return {
|
||||
id: String(raw.albumid ?? raw.album_id ?? raw.AlbumID ?? ""),
|
||||
name: (raw.albumname ?? raw.album_name ?? raw.AlbumName ?? "").toString(),
|
||||
artist: (raw.singername ?? raw.SingerName ?? raw.author_name ?? "").toString(),
|
||||
coverUrl: fixCover(raw.sizable_cover ?? raw.imgurl),
|
||||
songCount: Number(raw.songcount ?? raw.song_count ?? 0) || 0,
|
||||
platform: "kugou",
|
||||
};
|
||||
}
|
||||
|
||||
export function mapKugouAlbums(list: KugouRawAlbum[] | undefined): Album[] {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map(mapKugouAlbum);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
type CookieMap = Record<string, string>;
|
||||
|
||||
export class KugouProvider implements MusicProvider {
|
||||
readonly platform = "kugou" as const;
|
||||
|
||||
private readonly http: AxiosInstance;
|
||||
private readonly mobileHttp: AxiosInstance;
|
||||
private quality = "128";
|
||||
|
||||
// Device identity (generated once) + login cookies (persisted via CookieStore).
|
||||
private guid = "";
|
||||
private mid = "";
|
||||
private cookie: CookieMap = {};
|
||||
private dfidPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.http = axios.create({ timeout: 10000 });
|
||||
this.mobileHttp = axios.create({ timeout: 10000, headers: { "User-Agent": USER_AGENT } });
|
||||
this.ensureDevice();
|
||||
}
|
||||
|
||||
private ensureDevice(): void {
|
||||
if (this.guid) return;
|
||||
this.guid = md5(getGuid());
|
||||
this.mid = calculateMid(this.guid);
|
||||
}
|
||||
|
||||
// --- Cookie persistence (CookieStore stores a flat "k=v; k=v" string) ------
|
||||
setCookie(cookie: string): void {
|
||||
const map: CookieMap = {};
|
||||
for (const part of (cookie || "").split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const k = part.slice(0, eq).trim();
|
||||
const v = part.slice(eq + 1).trim();
|
||||
if (k) map[k] = v;
|
||||
}
|
||||
this.cookie = map;
|
||||
if (map.dfid) {
|
||||
/* keep registered device */
|
||||
}
|
||||
}
|
||||
|
||||
getCookie(): string {
|
||||
return Object.entries(this.cookie)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
setQuality(quality: string): void {
|
||||
this.quality = QUALITY_MAP[quality] ?? "128";
|
||||
}
|
||||
getQuality(): string {
|
||||
return this.quality;
|
||||
}
|
||||
|
||||
// --- Shared signed request pipeline (mirrors util/request.js) --------------
|
||||
private async request(opts: {
|
||||
method: "GET" | "POST";
|
||||
url: string;
|
||||
baseURL?: string;
|
||||
params?: Record<string, unknown>;
|
||||
data?: unknown;
|
||||
xRouter?: string;
|
||||
encryptType?: "android" | "web";
|
||||
encryptKey?: boolean;
|
||||
clearDefaultParams?: boolean;
|
||||
extraHeaders?: Record<string, string>;
|
||||
responseType?: "json" | "arraybuffer";
|
||||
/** Per-request device-fingerprint override (e.g. /v5/url uses a random one
|
||||
* when no real dfid has been registered, mirroring the reference). */
|
||||
dfid?: string;
|
||||
}): Promise<any> {
|
||||
this.ensureDevice();
|
||||
const dfid = opts.dfid || this.cookie.dfid || "-";
|
||||
const token = this.cookie.token || "";
|
||||
const userid = this.cookie.userid || "0";
|
||||
const clienttime = Math.floor(Date.now() / 1000);
|
||||
|
||||
const defaults: Record<string, unknown> = {
|
||||
dfid,
|
||||
mid: this.mid,
|
||||
uuid: "-",
|
||||
appid: APPID,
|
||||
clientver: CLIENTVER,
|
||||
clienttime,
|
||||
};
|
||||
if (token) defaults.token = token;
|
||||
if (userid && userid !== "0") defaults.userid = userid;
|
||||
|
||||
const params: Record<string, unknown> = opts.clearDefaultParams
|
||||
? { ...(opts.params ?? {}) }
|
||||
: { ...defaults, ...(opts.params ?? {}) };
|
||||
|
||||
if (opts.encryptKey) {
|
||||
params.key = signKey(String(params.hash ?? ""), this.mid, userid, APPID);
|
||||
}
|
||||
|
||||
const body = Buffer.isBuffer(opts.data)
|
||||
? opts.data
|
||||
: typeof opts.data === "object" && opts.data !== null
|
||||
? JSON.stringify(opts.data)
|
||||
: (opts.data ?? "");
|
||||
|
||||
if (params.signature === undefined) {
|
||||
params.signature =
|
||||
opts.encryptType === "web" ? signatureWeb(params) : signatureAndroid(params, typeof body === "string" ? body : "");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": USER_AGENT,
|
||||
dfid,
|
||||
clienttime: String(clienttime),
|
||||
mid: this.mid,
|
||||
"kg-rc": "1",
|
||||
"kg-thash": "5d816a0",
|
||||
"kg-rec": "1",
|
||||
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
|
||||
...(opts.xRouter ? { "x-router": opts.xRouter } : {}),
|
||||
...(opts.extraHeaders ?? {}),
|
||||
};
|
||||
|
||||
const res = await this.http.request({
|
||||
method: opts.method,
|
||||
baseURL: opts.baseURL ?? "https://gateway.kugou.com",
|
||||
url: opts.url,
|
||||
params,
|
||||
data: opts.data,
|
||||
headers,
|
||||
responseType: opts.responseType ?? "json",
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** Register a device fingerprint to obtain a `dfid` (required by /song/url). */
|
||||
private async ensureDfid(): Promise<void> {
|
||||
if (this.cookie.dfid) return;
|
||||
if (!this.dfidPromise) {
|
||||
this.dfidPromise = this.registerDev().catch(() => {
|
||||
/* swallow — handled below */
|
||||
});
|
||||
}
|
||||
await this.dfidPromise;
|
||||
// registerDev resolves WITHOUT throwing on a soft failure (HTTP 200 with an
|
||||
// in-body status!=1, e.g. risk-control), so a thrown-only retry would cache
|
||||
// the failure forever. Clear the memo whenever no dfid was obtained.
|
||||
if (!this.cookie.dfid) this.dfidPromise = null;
|
||||
}
|
||||
|
||||
private async registerDev(): Promise<void> {
|
||||
const dataMap = {
|
||||
availableRamSize: 4983533568,
|
||||
availableRomSize: 48114719,
|
||||
availableSDSize: 48114717,
|
||||
basebandVer: "",
|
||||
batteryLevel: 100,
|
||||
batteryStatus: 3,
|
||||
brand: "Redmi",
|
||||
buildSerial: "unknown",
|
||||
device: "marble",
|
||||
imei: this.guid,
|
||||
imsi: "",
|
||||
manufacturer: "Xiaomi",
|
||||
uuid: this.guid,
|
||||
accelerometer: false,
|
||||
accelerometerValue: "",
|
||||
gravity: false,
|
||||
gravityValue: "",
|
||||
gyroscope: false,
|
||||
gyroscopeValue: "",
|
||||
light: false,
|
||||
lightValue: "",
|
||||
magnetic: false,
|
||||
magneticValue: "",
|
||||
orientation: false,
|
||||
orientationValue: "",
|
||||
pressure: false,
|
||||
pressureValue: "",
|
||||
step_counter: false,
|
||||
step_counterValue: "",
|
||||
temperature: false,
|
||||
temperatureValue: "",
|
||||
};
|
||||
const aes = playlistAesEncrypt(dataMap);
|
||||
const p = rsaEncrypt2({ aes: aes.key, uid: this.cookie.userid || 0, token: this.cookie.token || "" });
|
||||
const raw = await this.request({
|
||||
method: "POST",
|
||||
baseURL: "https://userservice.kugou.com",
|
||||
url: "/risk/v2/r_register_dev",
|
||||
params: { part: 1, platid: 1, p },
|
||||
data: aes.str,
|
||||
encryptType: "android",
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
const decoded = playlistAesDecrypt(Buffer.from(raw).toString("base64"), aes.key) as {
|
||||
status?: number;
|
||||
data?: { dfid?: string };
|
||||
};
|
||||
if (decoded?.status === 1 && decoded.data?.dfid) {
|
||||
this.cookie.dfid = decoded.data.dfid;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search (verified live via the unsigned mobile endpoint) ---------------
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
const q = query.trim();
|
||||
if (!q) return { songs: [], playlists: [], albums: [] };
|
||||
try {
|
||||
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
|
||||
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 },
|
||||
});
|
||||
const info = res.data?.data?.info as KugouRawSong[] | undefined;
|
||||
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
||||
} catch {
|
||||
return { songs: [], playlists: [], albums: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Play URL --------------------------------------------------------------
|
||||
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||
const { hash, albumAudioId, albumId } = parseId(songId);
|
||||
if (!hash) return null;
|
||||
await this.ensureDfid();
|
||||
const q = quality ? (QUALITY_MAP[quality] ?? this.quality) : this.quality;
|
||||
try {
|
||||
const data = await this.request({
|
||||
method: "GET",
|
||||
url: "/v5/url",
|
||||
xRouter: "trackercdn.kugou.com",
|
||||
encryptKey: true,
|
||||
// /v5/url must never present "-": use the registered dfid, else a random
|
||||
// 24-char one (matches reference module/song_url.js).
|
||||
dfid: this.cookie.dfid || randomString(24),
|
||||
params: {
|
||||
album_id: Number(albumId) || 0,
|
||||
area_code: 1,
|
||||
hash,
|
||||
ssa_flag: "is_fromtrack",
|
||||
version: 11430,
|
||||
page_id: 151369488,
|
||||
quality: q,
|
||||
album_audio_id: Number(albumAudioId) || 0,
|
||||
behavior: "play",
|
||||
pid: 2,
|
||||
cmd: 26,
|
||||
pidversion: 3001,
|
||||
IsFreePart: 0,
|
||||
ppage_id: "463467626,350369493,788954147",
|
||||
cdnBackup: 1,
|
||||
module: "",
|
||||
clientver: 11430,
|
||||
},
|
||||
});
|
||||
const urls: string[] = Array.isArray(data?.url) ? data.url : [];
|
||||
const backup: string[] = Array.isArray(data?.backupUrl) ? data.backupUrl : [];
|
||||
const url = urls.find(Boolean) || backup.find(Boolean);
|
||||
if (!url) return null;
|
||||
return { url };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const { hash, albumAudioId, albumId } = parseId(songId);
|
||||
if (!hash) return null;
|
||||
// Kugou has no cheap "detail by hash" that adds value here; return a stub so
|
||||
// queue/history have a stable id+platform (mirrors qq.ts's fallback).
|
||||
return {
|
||||
id: makeId(hash, albumAudioId, albumId),
|
||||
name: "未知歌曲",
|
||||
artist: "未知歌手",
|
||||
album: "",
|
||||
duration: 0,
|
||||
coverUrl: "",
|
||||
platform: "kugou",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Lyrics (verified live) ------------------------------------------------
|
||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||
const { hash } = parseId(songId);
|
||||
if (!hash) return [];
|
||||
try {
|
||||
const search = await this.request({
|
||||
method: "GET",
|
||||
baseURL: "https://lyrics.kugou.com",
|
||||
url: "/v1/search",
|
||||
clearDefaultParams: true,
|
||||
params: { album_audio_id: 0, appid: APPID, clientver: CLIENTVER, duration: 0, hash, keyword: "", lrctxt: 1, man: "no" },
|
||||
});
|
||||
const candidate = search?.candidates?.[0];
|
||||
if (!candidate?.id || !candidate?.accesskey) return [];
|
||||
const dl = await this.request({
|
||||
method: "GET",
|
||||
baseURL: "https://lyrics.kugou.com",
|
||||
url: "/download",
|
||||
clearDefaultParams: true,
|
||||
params: { ver: 1, client: "android", id: candidate.id, accesskey: candidate.accesskey, fmt: "krc", charset: "utf8" },
|
||||
});
|
||||
if (!dl?.content) return [];
|
||||
const isPlain = dl.fmt === "lrc" || Number(dl.contenttype) !== 0;
|
||||
const text = isPlain ? Buffer.from(dl.content, "base64").toString("utf8") : krcToLrc(decodeKrc(dl.content));
|
||||
return parseLyrics(text);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Playlist (歌单, by global_collection_id) ------------------------------
|
||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
const PAGE_SIZE = 30;
|
||||
const out: Song[] = [];
|
||||
try {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const data = await this.request({
|
||||
method: "GET",
|
||||
url: "/pubsongs/v2/get_other_list_file_nofilt",
|
||||
params: {
|
||||
area_code: 1,
|
||||
begin_idx: i * PAGE_SIZE,
|
||||
plat: 1,
|
||||
type: 1,
|
||||
mode: 1,
|
||||
personal_switch: 1,
|
||||
extend_fields: "abtags,hot_cmt,popularization",
|
||||
pagesize: PAGE_SIZE,
|
||||
global_collection_id: playlistId,
|
||||
},
|
||||
});
|
||||
const list = (data?.data?.songs ?? data?.data?.info ?? data?.songs ?? []) as KugouNestedTrack[];
|
||||
out.push(...mapKugouSongs(list));
|
||||
// Break on the RAW count so filtered-out tracks don't truncate early.
|
||||
if (list.length < PAGE_SIZE) break;
|
||||
}
|
||||
} catch {
|
||||
// return whatever was collected so far
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||
try {
|
||||
const data = await this.request({
|
||||
method: "POST",
|
||||
url: "/v3/get_list_info",
|
||||
xRouter: "pubsongs.kugou.com",
|
||||
data: { data: [{ global_collection_id: playlistId }], userid: this.cookie.userid || 0, token: this.cookie.token || "" },
|
||||
});
|
||||
const info = data?.data?.[0] ?? data?.data;
|
||||
if (!info) return null;
|
||||
return {
|
||||
id: playlistId,
|
||||
name: info.name ?? info.list_name ?? "",
|
||||
description: info.intro ?? "",
|
||||
coverUrl: fixCover(info.pic ?? info.flexible_cover),
|
||||
songCount: Number(info.count ?? info.song_count ?? 0) || 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Recommend playlists need a logged-in/personalized context that can't be
|
||||
// reliably resolved anonymously; return empty rather than ship a wrong guess.
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Album (专辑) ----------------------------------------------------------
|
||||
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||
// Kugou caps album pagesize at 50 (larger → "invalid param"); paginate.
|
||||
const PAGE_SIZE = 50;
|
||||
const out: Song[] = [];
|
||||
let rawFetched = 0;
|
||||
try {
|
||||
for (let page = 1; page <= 8; page++) {
|
||||
const data = await this.request({
|
||||
method: "POST",
|
||||
url: "/v1/album_audio/lite",
|
||||
xRouter: "openapi.kugou.com",
|
||||
extraHeaders: { "kg-tid": "255" },
|
||||
data: { album_id: albumId, is_buy: "", page, pagesize: PAGE_SIZE },
|
||||
});
|
||||
const list = (data?.data?.songs ?? []) as KugouNestedTrack[];
|
||||
out.push(...mapKugouSongs(list));
|
||||
rawFetched += list.length;
|
||||
const total = Number(data?.data?.total ?? 0);
|
||||
// Decide on the RAW page size — filtered-out (unplayable) tracks must
|
||||
// not end pagination early and truncate the album.
|
||||
if (list.length < PAGE_SIZE || (total && rawFetched >= total)) break;
|
||||
}
|
||||
} catch {
|
||||
// return whatever was collected so far
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Personal FM (个性化电台) ---------------------------------------------
|
||||
async getPersonalFm(): Promise<Song[]> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const userid = this.cookie.userid && this.cookie.userid !== "0" ? this.cookie.userid : "";
|
||||
const identity: Record<string, unknown> = {};
|
||||
if (userid) {
|
||||
// Convey the logged-in identity to the recommender (reference personal_fm.js).
|
||||
identity.userid = userid;
|
||||
identity.kguid = userid;
|
||||
}
|
||||
if (this.cookie.token) identity.token = this.cookie.token;
|
||||
if (this.cookie.vip_type) identity.vip_type = this.cookie.vip_type;
|
||||
const data = await this.request({
|
||||
method: "POST",
|
||||
url: "/v2/personal_recommend",
|
||||
xRouter: "persnfm.service.kugou.com",
|
||||
data: {
|
||||
appid: APPID,
|
||||
clienttime: now,
|
||||
mid: this.mid,
|
||||
action: "play",
|
||||
recommend_source_locked: 0,
|
||||
song_pool_id: 0,
|
||||
callerid: 0,
|
||||
m_type: 1,
|
||||
platform: "ios",
|
||||
area_code: 1,
|
||||
remain_songcnt: 0,
|
||||
clientver: CLIENTVER,
|
||||
is_overplay: 0,
|
||||
mode: "normal",
|
||||
fakem: "ca981cfc583a4c37f28d2d49000013c16a0a",
|
||||
key: signParamsKey(now),
|
||||
...identity,
|
||||
},
|
||||
});
|
||||
const songs = data?.data?.song_list ?? data?.data?.songs ?? [];
|
||||
return mapKugouSongs(songs as KugouRawSong[]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- QR login --------------------------------------------------------------
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
const data = await this.request({
|
||||
method: "GET",
|
||||
baseURL: "https://login-user.kugou.com",
|
||||
url: "/v2/qrcode",
|
||||
encryptType: "web",
|
||||
params: {
|
||||
appid: 1001,
|
||||
type: 1,
|
||||
plat: 4,
|
||||
qrcode_txt: "https://h5.kugou.com/apps/loginQRCode/html/index.html?appid=1005&",
|
||||
srcappid: SRCAPPID,
|
||||
},
|
||||
});
|
||||
const key = data?.data?.qrcode ?? "";
|
||||
return {
|
||||
key,
|
||||
qrUrl: `https://h5.kugou.com/apps/loginQRCode/html/index.html?qrcode=${key}`,
|
||||
};
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(key: string): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||
try {
|
||||
const data = await this.request({
|
||||
method: "GET",
|
||||
baseURL: "https://login-user.kugou.com",
|
||||
url: "/v2/get_userinfo_qrcode",
|
||||
encryptType: "web",
|
||||
params: { plat: 4, appid: APPID, srcappid: SRCAPPID, qrcode: key },
|
||||
});
|
||||
const status = Number(data?.data?.status);
|
||||
if (status === 4) {
|
||||
// Success: persist token + userid for subsequent (VIP) playback.
|
||||
if (data.data.token) this.cookie.token = String(data.data.token);
|
||||
if (data.data.userid) this.cookie.userid = String(data.data.userid);
|
||||
if (data.data.nickname) this.cookie.nickname = String(data.data.nickname);
|
||||
return "confirmed";
|
||||
}
|
||||
if (status === 2) return "scanned";
|
||||
if (status === 1) return "waiting";
|
||||
return "expired";
|
||||
} catch {
|
||||
return "expired";
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
if (!this.cookie.token || !this.cookie.userid || this.cookie.userid === "0") {
|
||||
return { loggedIn: false };
|
||||
}
|
||||
return {
|
||||
loggedIn: true,
|
||||
nickname: this.safeNickname(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Kugou nicknames may arrive percent-encoded; decode defensively so a bare
|
||||
* '%' can't throw and turn /api/auth/status into a 500 (which would blank
|
||||
* EVERY platform's card, since the UI batches the four status calls). */
|
||||
private safeNickname(): string {
|
||||
const raw = this.cookie.nickname;
|
||||
if (!raw) return "酷狗用户";
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number; // seconds
|
||||
coverUrl: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
|
||||
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
|
||||
vip?: boolean;
|
||||
@@ -27,7 +27,7 @@ export interface Playlist {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
@@ -44,7 +44,7 @@ export interface Album {
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
@@ -72,7 +72,7 @@ export interface AuthStatus {
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
|
||||
search(query: string, limit?: number): Promise<SearchResult>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||
|
||||
+5
-1
@@ -11,7 +11,8 @@ export function createAuthRouter(
|
||||
qqProvider: MusicProvider,
|
||||
bilibiliProvider: MusicProvider,
|
||||
logger: Logger,
|
||||
cookieStore?: CookieStore
|
||||
cookieStore?: CookieStore,
|
||||
kugouProvider?: MusicProvider
|
||||
): Router {
|
||||
const router = Router();
|
||||
// YouTube is auth-less; we only use this instance so /auth/status can
|
||||
@@ -21,6 +22,7 @@ export function createAuthRouter(
|
||||
function getProvider(platform?: string): MusicProvider {
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -65,6 +67,7 @@ export function createAuthRouter(
|
||||
if (status === "confirmed") {
|
||||
const cookie = provider.getCookie();
|
||||
const plat = (platform as string) === "bilibili" ? "bilibili" as const
|
||||
: (platform as string) === "kugou" ? "kugou" as const
|
||||
: (platform as string) === "qq" ? "qq" as const : "netease" as const;
|
||||
if (cookie && cookieStore) {
|
||||
cookieStore.save(plat, cookie);
|
||||
@@ -137,6 +140,7 @@ export function createAuthRouter(
|
||||
const provider = getProvider(platform);
|
||||
provider.setCookie(cookie);
|
||||
const plat = platform === "bilibili" ? "bilibili" as const
|
||||
: platform === "kugou" ? "kugou" as const
|
||||
: platform === "qq" ? "qq" as const : "netease" as const;
|
||||
if (cookieStore) {
|
||||
cookieStore.save(plat, cookie);
|
||||
|
||||
+10
-2
@@ -13,7 +13,8 @@ export function createMusicRouter(
|
||||
bilibiliProvider: MusicProvider,
|
||||
logger: Logger,
|
||||
localProvider?: MusicProvider,
|
||||
config?: BotConfig
|
||||
config?: BotConfig,
|
||||
kugouProvider?: MusicProvider
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
@@ -26,6 +27,7 @@ export function createMusicRouter(
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "local" && localProvider) return localProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -111,11 +113,12 @@ export function createMusicRouter(
|
||||
return;
|
||||
}
|
||||
const parsedLimit = parseInt(limit as string) || 20;
|
||||
const [neteaseResult, qqResult, bilibiliResult, localResult] = await Promise.allSettled([
|
||||
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||
neteaseProvider.search(q as string, parsedLimit),
|
||||
qqProvider.search(q as string, parsedLimit),
|
||||
bilibiliProvider.search(q as string, parsedLimit),
|
||||
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
]);
|
||||
|
||||
const songs = [
|
||||
@@ -123,6 +126,7 @@ export function createMusicRouter(
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
|
||||
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
|
||||
];
|
||||
const albums = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||
@@ -286,6 +290,7 @@ export function createMusicRouter(
|
||||
qq: qqProvider.getQuality(),
|
||||
bilibili: bilibiliProvider.getQuality(),
|
||||
local: localProvider?.getQuality() ?? "original",
|
||||
kugou: kugouProvider?.getQuality() ?? "128",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,6 +310,9 @@ export function createMusicRouter(
|
||||
if (!platform || platform === "bilibili") {
|
||||
bilibiliProvider.setQuality(quality);
|
||||
}
|
||||
if ((!platform || platform === "kugou") && kugouProvider) {
|
||||
kugouProvider.setQuality(quality);
|
||||
}
|
||||
logger.info({ quality, platform }, "Audio quality changed");
|
||||
res.json({ success: true, quality });
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createPlayerRouter(
|
||||
if (platform === "bilibili") return "-b";
|
||||
if (platform === "qq") return "-q";
|
||||
if (platform === "youtube") return "-y";
|
||||
if (platform === "kugou") return "-k";
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -115,7 +116,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -290,7 +291,7 @@ export function createPlayerRouter(
|
||||
// Use the bot's own provider lookup — it already knows about youtube,
|
||||
// which the router's constructor params did not.
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -383,7 +384,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -614,7 +615,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
+3
-2
@@ -39,6 +39,7 @@ export interface WebServerOptions {
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
localProvider: MusicProvider;
|
||||
kugouProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
configPath: string;
|
||||
@@ -124,7 +125,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config)
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider)
|
||||
);
|
||||
app.use("/api/player", createPlayerRouter(
|
||||
options.botManager, logger, options.database,
|
||||
@@ -132,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
));
|
||||
app.use(
|
||||
"/api/auth",
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider)
|
||||
);
|
||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
||||
|
||||
|
||||
Reference in new issue
Block a user