Files
teamspeak-music-bot/src/music/qq.ts
T
2026-04-20 23:48:46 +08:00

275 lines
9.2 KiB
TypeScript

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 = "";
private quality = "exhigh";
constructor(baseUrl: string) {
this.api = axios.create({
baseURL: baseUrl,
timeout: 10000,
});
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
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("/getSearchByKey", {
params: { key: query, pageSize: limit, ...this.cookieParams },
});
const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map(
(s: any) => ({
id: String(s.mid ?? s.songmid ?? s.songid),
name: s.songname ?? "",
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, quality?: string): Promise<string | null> {
try {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return playUrl.url;
} catch {
// try with songid
try {
const res = await this.api.get("/getMusicPlay", {
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return playUrl.url;
} catch {
// ignore
}
}
return null;
}
async getSongDetail(songId: string): Promise<Song | null> {
// Try /getSongInfo for full metadata, but fall through to a minimal
// stub if the library endpoint fails (current @sansenjian/qq-music-api
// returns upstream code 500001 for this route — the param format it
// sends doesn't match QQ's current API). The bot's resolveAndPlay path
// only needs `id` and `platform` to fetch a play URL, and the fallback
// stub is sufficient to let /play-by-id and /add-by-id flows succeed.
try {
const res = await this.api.get("/getSongInfo", {
params: { songmid: songId, ...this.cookieParams },
});
const s = res.data?.response?.data;
if (s && s.track_info) {
const t = s.track_info;
return {
id: String(t.mid ?? t.id),
name: t.name ?? "",
artist: (t.singer ?? []).map((a: any) => a.name).join(" / "),
album: t.album?.name ?? "",
duration: t.interval ?? 0,
coverUrl: t.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${t.album.mid}.jpg`
: "",
platform: "qq",
};
}
} catch {
// fall through to stub
}
// Minimal stub — resolveAndPlay only needs id + platform to fetch a
// play URL. Name/artist/album will be empty in play history, but the
// song will actually play, which is the important part.
return {
id: songId,
name: "",
artist: "",
album: "",
duration: 0,
coverUrl: "",
platform: "qq",
};
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/getSongListDetail", {
params: { disstid: playlistId, ...this.cookieParams },
});
const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({
id: String(s.mid ?? s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: 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("/getSongLists", {
params: { categoryId: 10000000, pageSize: 10, ...this.cookieParams },
});
return (res.data?.response?.data?.list ?? []).map((p: any) => ({
id: String(p.dissid),
name: p.dissname ?? "",
coverUrl: p.imgurl ?? "",
songCount: p.listennum ?? 0,
platform: "qq",
}));
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
const res = await this.api.get("/getAlbumInfo", {
params: { albummid: albumId, ...this.cookieParams },
});
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
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("/getLyric", {
params: { songmid: songId, ...this.cookieParams },
});
return parseLyrics(
res.data?.response?.lyric ?? res.data?.lyric ?? "",
res.data?.response?.trans ?? res.data?.trans ?? ""
);
}
async getQrCode(): Promise<QrCodeResult> {
// @sansenjian/qq-music-api 2.x returns { img, qrsig, ptqrtoken } via
// customResponse (no { response: ... } wrapping). /checkQQLoginQr
// requires BOTH qrsig AND ptqrtoken — passing only one gives a 400
// "参数错误". Pack both into the opaque `key` field so the polling
// endpoint can split them back out. Separator "|" is safe: QQ tokens
// are alphanumeric.
const res = await this.api.get("/getQQLoginQr");
const qrsig: string = res.data?.qrsig ?? "";
const ptqrtoken: string = String(res.data?.ptqrtoken ?? "");
return {
qrUrl: "",
qrImg: res.data?.img ?? "",
key: `${qrsig}|${ptqrtoken}`,
};
}
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const [qrsig, ptqrtoken] = key.split("|");
if (!qrsig || !ptqrtoken) return "expired";
// NOTE: /checkQQLoginQr is registered as POST only in
// @sansenjian/qq-music-api 2.x. GET returns 405 Method Not Allowed.
let res;
try {
res = await this.api.post("/checkQQLoginQr", null, {
params: { qrsig, ptqrtoken },
});
} catch {
return "expired";
}
// customResponse shape:
// success: { isOk: true, message: '登录成功', session: { cookie, ... } }
// scanning: { isOk: false, refresh: false, message: '未扫描二维码' }
// expired: { isOk: false, refresh: true, message: '二维码已失效' }
const body = res.data;
if (body?.isOk === true) {
const cookie: string = body.session?.cookie ?? "";
if (cookie) this.cookie = cookie;
return "confirmed";
}
if (body?.refresh === true) return "expired";
if (typeof body?.message === "string" && body.message.includes("未扫描"))
return "waiting";
return "waiting";
}
setCookie(cookie: string): void {
this.cookie = cookie;
}
getCookie(): string {
return this.cookie;
}
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
// /getUserAvatar in @sansenjian/qq-music-api 2.x is NOT registered on
// the main router; the real endpoint is /user/getUserAvatar, and even
// that just builds a static URL from a uin without validating the
// cookie against QQ. Round-trip through /user/getUserPlaylists which
// actually hits QQ Music with the cookie; if the upstream returns
// code=0, the cookie is valid.
//
// IMPORTANT: /user/getUserPlaylists requires `uin` as a query param —
// the library 400s with "缺少 uin 参数" otherwise. Parse it out of the
// cookie (uin=<qq>; comes after the various *uin prefixed names, which
// is why the regex anchors on a word boundary).
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
const uin = uinMatch ? uinMatch[1] : "";
if (!uin) return { loggedIn: false };
try {
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, ...this.cookieParams },
});
if (res.data?.response?.code !== 0) return { loggedIn: false };
return {
loggedIn: true,
nickname: `QQ ${uin}`,
avatarUrl: `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100`,
};
} catch {
return { loggedIn: false };
}
}
}