mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
275 lines
9.2 KiB
TypeScript
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 };
|
|
}
|
|
}
|
|
}
|