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