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

+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));