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:
saopig1andClaude Opus 4.8 committed 2026-06-30 18:55:41 +08:00
1 parent af326a37b7
commit a12c419dd2
18 files changed
+1109 -32

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+100
View File
@@ -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);
});
});
+840
View File
@@ -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;
}
}
}
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
});
+5 -4
View File
@@ -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
View File
@@ -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));