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 { return this.cookie ? { cookie: this.cookie } : {}; } async search(query: string, limit = 20): Promise { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { // @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 { 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=; 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 }; } } }