From a12c419dd2aa9fc6e572dbf0545ff544c6421dd0 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Tue, 30 Jun 2026 18:55:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(music):=20add=20Kugou=20(=E9=85=B7?= =?UTF-8?q?=E7=8B=97=E9=9F=B3=E4=B9=90)=20as=20a=20music=20source=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/audio/queue.ts | 2 +- src/bot/instance.ts | 7 +- src/bot/manager.ts | 8 +- src/data/database.ts | 2 +- src/index.ts | 8 +- src/music/auth.ts | 8 +- src/music/kugou.test.ts | 100 ++++ src/music/kugou.ts | 840 ++++++++++++++++++++++++++++++++ src/music/provider.ts | 8 +- src/web/api/auth.ts | 6 +- src/web/api/music.ts | 12 +- src/web/api/player.ts | 9 +- src/web/server.ts | 5 +- web/src/components/SongCard.vue | 9 +- web/src/stores/player.ts | 2 +- web/src/styles/variables.scss | 2 + web/src/views/Search.vue | 22 +- web/src/views/Settings.vue | 91 +++- 18 files changed, 1109 insertions(+), 32 deletions(-) create mode 100644 src/music/kugou.test.ts create mode 100644 src/music/kugou.ts diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 617e7d9..221b74b 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -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 diff --git a/src/bot/instance.ts b/src/bot/instance.ts index fb54b87..8d219fb 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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; } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index bc7e711..8e5310d 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -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, diff --git a/src/data/database.ts b/src/data/database.ts index 2140eb3..b08ca19 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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; } diff --git a/src/index.ts b/src/index.ts index fbb3871..9eebc07 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/music/auth.ts b/src/music/auth.ts index 1f6272f..70b30c1 100644 --- a/src/music/auth.ts +++ b/src/music/auth.ts @@ -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 { diff --git a/src/music/kugou.test.ts b/src/music/kugou.test.ts new file mode 100644 index 0000000..3680787 --- /dev/null +++ b/src/music/kugou.test.ts @@ -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); + }); +}); diff --git a/src/music/kugou.ts b/src/music/kugou.ts new file mode 100644 index 0000000..da05986 --- /dev/null +++ b/src/music/kugou.ts @@ -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 = { + 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, 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 { + 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 `` 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; + +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 | 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; + data?: unknown; + xRouter?: string; + encryptType?: "android" | "web"; + encryptKey?: boolean; + clearDefaultParams?: boolean; + extraHeaders?: Record; + 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 { + 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 = { + 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 = 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 = { + "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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return []; + } + + // --- Album (专辑) ---------------------------------------------------------- + async getAlbumSongs(albumId: string): Promise { + // 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 { + try { + const now = Date.now(); + const userid = this.cookie.userid && this.cookie.userid !== "0" ? this.cookie.userid : ""; + const identity: Record = {}; + 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 { + 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 { + 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; + } + } +} diff --git a/src/music/provider.ts b/src/music/provider.ts index 4b4b4cc..6c66bd9 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -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; getSongUrl(songId: string, quality?: string): Promise; diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts index 9bfc0a0..daed411 100644 --- a/src/web/api/auth.ts +++ b/src/web/api/auth.ts @@ -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); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 40b84c9..a80545e 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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 }); }); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 68e9b59..0da271e 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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" ); diff --git a/src/web/server.ts b/src/web/server.ts index 8f3ef89..16da691 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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)); diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index 59232e1..9dbd3de 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -7,8 +7,8 @@ {{ song.name }} {{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : '网易云' }} + :class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : 'badge-netease'" + >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : '网易云' }}
{{ song.artist }}
@@ -140,6 +140,11 @@ function formatDuration(seconds: number): string { color: var(--color-primary); } +.badge-kugou { + background: var(--brand-kugou-12); + color: var(--brand-kugou); +} + .song-artist { font-size: 12px; color: var(--text-secondary); diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 3773096..8ac781f 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -10,7 +10,7 @@ export interface Song { album: string; duration: number; coverUrl: string; - platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local'; + platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou'; } export type Source = 'netease' | 'qq'; diff --git a/web/src/styles/variables.scss b/web/src/styles/variables.scss index e65ab4c..1e2d32a 100644 --- a/web/src/styles/variables.scss +++ b/web/src/styles/variables.scss @@ -80,6 +80,8 @@ --brand-bilibili-15: rgba(0, 161, 214, 0.15); --brand-youtube: #ff0000; --brand-youtube-12: rgba(255, 0, 0, 0.12); + --brand-kugou: #2ca2f9; + --brand-kugou-12: rgba(44, 162, 249, 0.12); } // Dark theme (default) diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index a34408d..e70d1e5 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -72,6 +72,11 @@ :class="{ active: selectedSource === 'bilibili' }" @click="selectedSource = 'bilibili'" >B站 + + + +