mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: add music source service — NetEase/QQ providers, unified interface, cookie auth
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
17d2decc91
commit
94902fc2c4
8 files changed
+2081
-3
No files matched your search
Generated
+1407
-3
File diff suppressed because it is too large.
Load diff
@@ -13,8 +13,10 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
|
"axios": "^1.14.0",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"NeteaseCloudMusicApi": "^4.30.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
|
export interface ApiServerOptions {
|
||||||
|
neteasePort: number;
|
||||||
|
qqMusicPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiServerManager {
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
getNeteaseBaseUrl(): string;
|
||||||
|
getQQMusicBaseUrl(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiServerManager(
|
||||||
|
options: ApiServerOptions,
|
||||||
|
logger: Logger
|
||||||
|
): ApiServerManager {
|
||||||
|
let neteaseReady = false;
|
||||||
|
let qqMusicReady = false;
|
||||||
|
|
||||||
|
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
||||||
|
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async start(): Promise<void> {
|
||||||
|
logger.info("Starting embedded music API servers...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
{ port: options.neteasePort },
|
||||||
|
"NetEase Cloud Music API starting"
|
||||||
|
);
|
||||||
|
neteaseReady = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info({ port: options.qqMusicPort }, "QQ Music API starting");
|
||||||
|
qqMusicReady = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{ err },
|
||||||
|
"QQ Music API not available, QQ Music features will be disabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
logger.info("Stopping music API servers");
|
||||||
|
neteaseReady = false;
|
||||||
|
qqMusicReady = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
getNeteaseBaseUrl(): string {
|
||||||
|
return neteaseBaseUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
getQQMusicBaseUrl(): string {
|
||||||
|
return qqMusicBaseUrl;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface CookieStore {
|
||||||
|
save(platform: "netease" | "qq", cookie: string): void;
|
||||||
|
load(platform: "netease" | "qq"): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCookieStore(cookieDir: string): CookieStore {
|
||||||
|
if (!fs.existsSync(cookieDir)) {
|
||||||
|
fs.mkdirSync(cookieDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
save(platform: "netease" | "qq", cookie: string): void {
|
||||||
|
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({ cookie, updatedAt: new Date().toISOString() }),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
load(platform: "netease" | "qq"): string {
|
||||||
|
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||||
|
if (!fs.existsSync(filePath)) return "";
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
return data.cookie ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
|
describe("NetEase adapter", () => {
|
||||||
|
it("parses LRC format lyrics", () => {
|
||||||
|
const lrc = `[00:00.00] 作词 : 周杰伦
|
||||||
|
[00:01.00] 作曲 : 周杰伦
|
||||||
|
[00:12.50]故事的小黄花
|
||||||
|
[00:15.80]从出生那年就飘着`;
|
||||||
|
|
||||||
|
const lines = parseLyrics(lrc);
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
expect(lines[0].time).toBeCloseTo(12.5, 1);
|
||||||
|
expect(lines[0].text).toBe("故事的小黄花");
|
||||||
|
expect(lines[1].time).toBeCloseTo(15.8, 1);
|
||||||
|
expect(lines[1].text).toBe("从出生那年就飘着");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty lyrics", () => {
|
||||||
|
const lines = parseLyrics("");
|
||||||
|
expect(lines).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges translation lyrics", () => {
|
||||||
|
const lrc = "[00:12.50]Hello world";
|
||||||
|
const tlyric = "[00:12.50]你好世界";
|
||||||
|
const lines = parseLyrics(lrc, tlyric);
|
||||||
|
expect(lines[0].text).toBe("Hello world");
|
||||||
|
expect(lines[0].translation).toBe("你好世界");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import axios, { type AxiosInstance } from "axios";
|
||||||
|
import type {
|
||||||
|
MusicProvider,
|
||||||
|
Song,
|
||||||
|
Playlist,
|
||||||
|
LyricLine,
|
||||||
|
SearchResult,
|
||||||
|
QrCodeResult,
|
||||||
|
AuthStatus,
|
||||||
|
} from "./provider.js";
|
||||||
|
|
||||||
|
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
|
||||||
|
if (!lrc) return [];
|
||||||
|
|
||||||
|
const parseLine = (
|
||||||
|
line: string
|
||||||
|
): { time: number; text: string } | null => {
|
||||||
|
const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\](.+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const minutes = parseInt(match[1], 10);
|
||||||
|
const seconds = parseInt(match[2], 10);
|
||||||
|
const ms = parseInt(match[3].padEnd(3, "0"), 10);
|
||||||
|
const text = match[4].trim();
|
||||||
|
|
||||||
|
if (/^(作词|作曲|编曲|制作|混音|母带)\s*[::]/.test(text)) return null;
|
||||||
|
|
||||||
|
return { time: minutes * 60 + seconds + ms / 1000, text };
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: LyricLine[] = [];
|
||||||
|
const translationMap = new Map<number, string>();
|
||||||
|
|
||||||
|
if (tlyric) {
|
||||||
|
for (const line of tlyric.split("\n")) {
|
||||||
|
const parsed = parseLine(line);
|
||||||
|
if (parsed) {
|
||||||
|
translationMap.set(Math.round(parsed.time * 100), parsed.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lrc.split("\n")) {
|
||||||
|
const parsed = parseLine(line);
|
||||||
|
if (parsed) {
|
||||||
|
const timeKey = Math.round(parsed.time * 100);
|
||||||
|
lines.push({
|
||||||
|
time: parsed.time,
|
||||||
|
text: parsed.text,
|
||||||
|
translation: translationMap.get(timeKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NeteaseProvider implements MusicProvider {
|
||||||
|
readonly platform = "netease" as const;
|
||||||
|
private api: AxiosInstance;
|
||||||
|
private cookie = "";
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.api = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private get cookieParams(): Record<string, string> {
|
||||||
|
return this.cookie ? { cookie: this.cookie } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
|
const [songRes, playlistRes] = await Promise.all([
|
||||||
|
this.api.get("/cloudsearch", {
|
||||||
|
params: { keywords: query, type: 1, limit, ...this.cookieParams },
|
||||||
|
}),
|
||||||
|
this.api.get("/cloudsearch", {
|
||||||
|
params: {
|
||||||
|
keywords: query,
|
||||||
|
type: 1000,
|
||||||
|
limit: 5,
|
||||||
|
...this.cookieParams,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
|
||||||
|
(s: any) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name,
|
||||||
|
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.al?.name ?? "",
|
||||||
|
duration: Math.round((s.dt ?? 0) / 1000),
|
||||||
|
coverUrl: s.al?.picUrl ?? "",
|
||||||
|
platform: "netease",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const playlists: Playlist[] = (
|
||||||
|
playlistRes.data?.result?.playlists ?? []
|
||||||
|
).map((p: any) => ({
|
||||||
|
id: String(p.id),
|
||||||
|
name: p.name,
|
||||||
|
coverUrl: p.coverImgUrl ?? "",
|
||||||
|
songCount: p.trackCount ?? 0,
|
||||||
|
platform: "netease",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { songs, playlists, albums: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSongUrl(songId: string): Promise<string | null> {
|
||||||
|
const res = await this.api.get("/song/url/v1", {
|
||||||
|
params: { id: songId, level: "exhigh", ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return res.data?.data?.[0]?.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
|
const res = await this.api.get("/song/detail", {
|
||||||
|
params: { ids: songId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
const s = res.data?.songs?.[0];
|
||||||
|
if (!s) return null;
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name,
|
||||||
|
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.al?.name ?? "",
|
||||||
|
duration: Math.round((s.dt ?? 0) / 1000),
|
||||||
|
coverUrl: s.al?.picUrl ?? "",
|
||||||
|
platform: "netease",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||||
|
const res = await this.api.get("/playlist/track/all", {
|
||||||
|
params: { id: playlistId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return (res.data?.songs ?? []).map((s: any) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name,
|
||||||
|
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.al?.name ?? "",
|
||||||
|
duration: Math.round((s.dt ?? 0) / 1000),
|
||||||
|
coverUrl: s.al?.picUrl ?? "",
|
||||||
|
platform: "netease",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||||
|
const res = await this.api.get("/personalized", {
|
||||||
|
params: { limit: 10, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return (res.data?.result ?? []).map((p: any) => ({
|
||||||
|
id: String(p.id),
|
||||||
|
name: p.name,
|
||||||
|
coverUrl: p.picUrl ?? "",
|
||||||
|
songCount: p.trackCount ?? 0,
|
||||||
|
platform: "netease",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||||
|
const res = await this.api.get("/album", {
|
||||||
|
params: { id: albumId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return (res.data?.songs ?? []).map((s: any) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name,
|
||||||
|
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.al?.name ?? "",
|
||||||
|
duration: Math.round((s.dt ?? 0) / 1000),
|
||||||
|
coverUrl: s.al?.picUrl ?? "",
|
||||||
|
platform: "netease",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
|
const res = await this.api.get("/lyric", {
|
||||||
|
params: { id: songId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return parseLyrics(
|
||||||
|
res.data?.lrc?.lyric ?? "",
|
||||||
|
res.data?.tlyric?.lyric
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQrCode(): Promise<QrCodeResult> {
|
||||||
|
const keyRes = await this.api.get("/login/qr/key");
|
||||||
|
const key = keyRes.data?.data?.unikey ?? "";
|
||||||
|
const createRes = await this.api.get("/login/qr/create", {
|
||||||
|
params: { key, qrimg: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
qrUrl: createRes.data?.data?.qrurl ?? "",
|
||||||
|
key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkQrCodeStatus(
|
||||||
|
key: string
|
||||||
|
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||||
|
const res = await this.api.get("/login/qr/check", {
|
||||||
|
params: { key },
|
||||||
|
});
|
||||||
|
const code = res.data?.code;
|
||||||
|
switch (code) {
|
||||||
|
case 801:
|
||||||
|
return "waiting";
|
||||||
|
case 802:
|
||||||
|
return "scanned";
|
||||||
|
case 803:
|
||||||
|
if (res.data?.cookie) {
|
||||||
|
this.cookie = res.data.cookie;
|
||||||
|
}
|
||||||
|
return "confirmed";
|
||||||
|
default:
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSmsCode(phone: string): Promise<boolean> {
|
||||||
|
const res = await this.api.get("/captcha/sent", {
|
||||||
|
params: { phone },
|
||||||
|
});
|
||||||
|
return res.data?.code === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithSms(phone: string, code: string): Promise<boolean> {
|
||||||
|
const res = await this.api.get("/captcha/verify", {
|
||||||
|
params: { phone, captcha: code },
|
||||||
|
});
|
||||||
|
if (res.data?.cookie) {
|
||||||
|
this.cookie = res.data.cookie;
|
||||||
|
}
|
||||||
|
return res.data?.code === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(cookie: string): void {
|
||||||
|
this.cookie = cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookie(): string {
|
||||||
|
return this.cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthStatus(): Promise<AuthStatus> {
|
||||||
|
if (!this.cookie) return { loggedIn: false };
|
||||||
|
try {
|
||||||
|
const res = await this.api.get("/login/status", {
|
||||||
|
params: { ...this.cookieParams },
|
||||||
|
});
|
||||||
|
const profile = res.data?.data?.profile;
|
||||||
|
if (profile) {
|
||||||
|
return {
|
||||||
|
loggedIn: true,
|
||||||
|
nickname: profile.nickname,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { loggedIn: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPersonalFm(): Promise<Song[]> {
|
||||||
|
const res = await this.api.get("/personal_fm", {
|
||||||
|
params: { ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return (res.data?.data ?? []).map((s: any) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name,
|
||||||
|
artist: (s.artists ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.album?.name ?? "",
|
||||||
|
duration: Math.round((s.duration ?? 0) / 1000),
|
||||||
|
coverUrl: s.album?.picUrl ?? "",
|
||||||
|
platform: "netease",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export interface Song {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number; // seconds
|
||||||
|
coverUrl: string;
|
||||||
|
platform: "netease" | "qq";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SongWithUrl extends Song {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
platform: "netease" | "qq";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
platform: "netease" | "qq";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LyricLine {
|
||||||
|
time: number; // seconds
|
||||||
|
text: string;
|
||||||
|
translation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
songs: Song[];
|
||||||
|
playlists: Playlist[];
|
||||||
|
albums: Album[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrCodeResult {
|
||||||
|
qrUrl: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
loggedIn: boolean;
|
||||||
|
nickname?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicProvider {
|
||||||
|
readonly platform: "netease" | "qq";
|
||||||
|
|
||||||
|
search(query: string, limit?: number): Promise<SearchResult>;
|
||||||
|
getSongUrl(songId: string): Promise<string | null>;
|
||||||
|
getSongDetail(songId: string): Promise<Song | null>;
|
||||||
|
getPlaylistSongs(playlistId: string): Promise<Song[]>;
|
||||||
|
getRecommendPlaylists(): Promise<Playlist[]>;
|
||||||
|
getAlbumSongs(albumId: string): Promise<Song[]>;
|
||||||
|
getLyrics(songId: string): Promise<LyricLine[]>;
|
||||||
|
getQrCode(): Promise<QrCodeResult>;
|
||||||
|
checkQrCodeStatus(
|
||||||
|
key: string
|
||||||
|
): Promise<"waiting" | "scanned" | "confirmed" | "expired">;
|
||||||
|
loginWithSms?(phone: string, code: string): Promise<boolean>;
|
||||||
|
sendSmsCode?(phone: string): Promise<boolean>;
|
||||||
|
setCookie(cookie: string): void;
|
||||||
|
getCookie(): string;
|
||||||
|
getAuthStatus(): Promise<AuthStatus>;
|
||||||
|
getPersonalFm?(): Promise<Song[]>;
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
import axios, { type AxiosInstance } from "axios";
|
||||||
|
import type {
|
||||||
|
MusicProvider,
|
||||||
|
Song,
|
||||||
|
Playlist,
|
||||||
|
LyricLine,
|
||||||
|
SearchResult,
|
||||||
|
QrCodeResult,
|
||||||
|
AuthStatus,
|
||||||
|
} from "./provider.js";
|
||||||
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
|
export class QQMusicProvider implements MusicProvider {
|
||||||
|
readonly platform = "qq" as const;
|
||||||
|
private api: AxiosInstance;
|
||||||
|
private cookie = "";
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.api = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private get cookieParams(): Record<string, string> {
|
||||||
|
return this.cookie ? { cookie: this.cookie } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
|
const res = await this.api.get("/search", {
|
||||||
|
params: { key: query, pageSize: limit, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
|
||||||
|
const songs: Song[] = (res.data?.data?.list ?? []).map((s: any) => ({
|
||||||
|
id: String(s.songmid ?? s.id),
|
||||||
|
name: s.songname ?? s.name ?? "",
|
||||||
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.albumname ?? "",
|
||||||
|
duration: s.interval ?? 0,
|
||||||
|
coverUrl: s.albummid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||||
|
: "",
|
||||||
|
platform: "qq",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { songs, playlists: [], albums: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSongUrl(songId: string): Promise<string | null> {
|
||||||
|
const res = await this.api.get("/song/url", {
|
||||||
|
params: { id: songId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return res.data?.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
|
const res = await this.api.get("/song", {
|
||||||
|
params: { songmid: songId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
const s = res.data?.data;
|
||||||
|
if (!s) return null;
|
||||||
|
return {
|
||||||
|
id: String(s.mid ?? s.id),
|
||||||
|
name: s.name ?? "",
|
||||||
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.album?.name ?? "",
|
||||||
|
duration: s.interval ?? 0,
|
||||||
|
coverUrl: s.album?.mid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||||
|
: "",
|
||||||
|
platform: "qq",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||||
|
const res = await this.api.get("/songlist", {
|
||||||
|
params: { id: playlistId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return (res.data?.data?.songlist ?? []).map((s: any) => ({
|
||||||
|
id: String(s.songmid ?? s.id),
|
||||||
|
name: s.songname ?? s.name ?? "",
|
||||||
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.albumname ?? "",
|
||||||
|
duration: s.interval ?? 0,
|
||||||
|
coverUrl: s.albummid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||||
|
: "",
|
||||||
|
platform: "qq",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||||
|
const res = await this.api.get("/recommend/playlist", {
|
||||||
|
params: { ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return (res.data?.data?.list ?? []).map((p: any) => ({
|
||||||
|
id: String(p.content_id ?? p.id),
|
||||||
|
name: p.title ?? p.name ?? "",
|
||||||
|
coverUrl: p.cover ?? "",
|
||||||
|
songCount: p.cnt ?? 0,
|
||||||
|
platform: "qq",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||||
|
const res = await this.api.get("/album/songs", {
|
||||||
|
params: { albummid: albumId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return (res.data?.data?.list ?? []).map((s: any) => ({
|
||||||
|
id: String(s.songmid ?? s.id),
|
||||||
|
name: s.songname ?? s.name ?? "",
|
||||||
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||||
|
album: s.albumname ?? "",
|
||||||
|
duration: s.interval ?? 0,
|
||||||
|
coverUrl: s.albummid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||||
|
: "",
|
||||||
|
platform: "qq",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
|
const res = await this.api.get("/lyric", {
|
||||||
|
params: { songmid: songId, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
return parseLyrics(
|
||||||
|
res.data?.data?.lyric ?? "",
|
||||||
|
res.data?.data?.trans ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQrCode(): Promise<QrCodeResult> {
|
||||||
|
const res = await this.api.get("/login/qr/create");
|
||||||
|
return {
|
||||||
|
qrUrl: res.data?.data?.qrurl ?? "",
|
||||||
|
key: res.data?.data?.key ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkQrCodeStatus(
|
||||||
|
key: string
|
||||||
|
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||||
|
const res = await this.api.get("/login/qr/check", {
|
||||||
|
params: { key },
|
||||||
|
});
|
||||||
|
const code = res.data?.data?.code;
|
||||||
|
if (code === 0) {
|
||||||
|
if (res.data?.data?.cookie) {
|
||||||
|
this.cookie = res.data.data.cookie;
|
||||||
|
}
|
||||||
|
return "confirmed";
|
||||||
|
}
|
||||||
|
if (code === 1) return "scanned";
|
||||||
|
if (code === 2) return "waiting";
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(cookie: string): void {
|
||||||
|
this.cookie = cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookie(): string {
|
||||||
|
return this.cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthStatus(): Promise<AuthStatus> {
|
||||||
|
if (!this.cookie) return { loggedIn: false };
|
||||||
|
try {
|
||||||
|
const res = await this.api.get("/user/detail", {
|
||||||
|
params: { ...this.cookieParams },
|
||||||
|
});
|
||||||
|
if (res.data?.data) {
|
||||||
|
return {
|
||||||
|
loggedIn: true,
|
||||||
|
nickname: res.data.data.nickname,
|
||||||
|
avatarUrl: res.data.data.headpic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { loggedIn: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user