mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 05:52:50 +08:00
1035 lines
38 KiB
TypeScript
1035 lines
38 KiB
TypeScript
import axios, { type AxiosInstance } from "axios";
|
|
import type {
|
|
MusicProvider,
|
|
Song,
|
|
SongUrlResult,
|
|
Playlist,
|
|
PlaylistDetail,
|
|
LyricLine,
|
|
SearchResult,
|
|
QrCodeResult,
|
|
AuthStatus,
|
|
Album,
|
|
Artist,
|
|
ArtistDetail,
|
|
ArtistSongPage,
|
|
} from "./provider.js";
|
|
import { parseLyrics } from "./netease.js";
|
|
|
|
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
|
|
// batch). Was broken ca. 2026-05 due to two upstream API changes:
|
|
// 1. searchid param must NOT be present (causes all lists to be empty)
|
|
// 2. num_per_page must be >= 10 (lower values return empty)
|
|
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
|
const qqMusicuApi = axios.create({
|
|
baseURL: "https://u.y.qq.com",
|
|
timeout: 10000,
|
|
headers: { referer: "https://y.qq.com" },
|
|
});
|
|
|
|
// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
|
|
// endpoint, song + album only, no playlist support).
|
|
const qqSearchApi = axios.create({
|
|
baseURL: "https://c.y.qq.com",
|
|
timeout: 10000,
|
|
headers: { referer: "https://y.qq.com" },
|
|
});
|
|
|
|
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
|
|
// The bundled qq-music-api wrapper doesn't expose these endpoints.
|
|
const qqFavApi = axios.create({
|
|
baseURL: "https://c.y.qq.com",
|
|
timeout: 10000,
|
|
headers: { referer: "https://y.qq.com/" },
|
|
});
|
|
|
|
/** True when a search_type=2 album search entry really belongs to this singer.
|
|
* QQ fills singerMID for most albums; older entries only carry singer_list. */
|
|
function isArtistAlbum(a: any, artistId: string): boolean {
|
|
const mid = a?.singerMID ?? a?.singer_mid;
|
|
if (mid) return String(mid) === artistId;
|
|
const singers = a?.singer_list ?? a?.singer ?? [];
|
|
return (
|
|
Array.isArray(singers) &&
|
|
singers.some((s: any) => String(s?.mid ?? s?.singerMID ?? "") === artistId)
|
|
);
|
|
}
|
|
|
|
/** Assembling a QQ singer's full catalogue costs one album-song request per
|
|
* album, so the merged list is memoised per singer for a while. */
|
|
const ARTIST_CATALOG_TTL_MS = 10 * 60 * 1000;
|
|
const ARTIST_CATALOG_MAX_ENTRIES = 20;
|
|
/** Bound pathological search responses even when every album is empty or all
|
|
* tracks are duplicates. Hitting this guard is an incomplete, uncached scan. */
|
|
const ARTIST_ALBUM_MAX_PAGES = 100;
|
|
const ARTIST_ALBUM_CONCURRENCY = 5;
|
|
const ARTIST_CATALOG_MAX_SONGS = 500;
|
|
|
|
interface ArtistCatalog {
|
|
songs: Song[];
|
|
total: number;
|
|
incomplete: boolean;
|
|
}
|
|
|
|
/** A malformed row must not disappear in the mapper and make an incomplete
|
|
* artist catalogue look like a successful, cacheable empty album. */
|
|
function isQqSongRow(raw: unknown): boolean {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
|
const song = raw as Record<string, unknown>;
|
|
const id = song.mid ?? song.songmid ?? song.songMID ?? song.id ?? song.songid ?? song.songId;
|
|
return typeof id === "string"
|
|
? id.trim().length > 0
|
|
: typeof id === "number" && Number.isSafeInteger(id) && id > 0;
|
|
}
|
|
|
|
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.map((s) => {
|
|
const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? "";
|
|
return {
|
|
id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""),
|
|
name: s.title ?? s.name ?? s.songname ?? "",
|
|
artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
|
|
album: s.album?.name ?? s.album?.title ?? s.albumname ?? "",
|
|
duration: s.interval ?? Math.round((s.duration ?? 0) / 1000),
|
|
coverUrl: albumMid
|
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
|
: "",
|
|
platform: "qq" as const,
|
|
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
|
|
};
|
|
}).filter((s) => s.id);
|
|
}
|
|
|
|
/** 解析 QQ 试听标记 → 试听秒数;非试听(VIP/免费)返回 undefined。
|
|
* 字段 isTryout===1 / tryout===true + tryBegin/tryEnd;容忍 begin/start 别名 + 毫秒兜底。 */
|
|
export function parseQqTrial(playUrl: any): number | undefined {
|
|
if (!playUrl || typeof playUrl !== "object") return undefined;
|
|
if (playUrl.isTryout !== 1 && playUrl.tryout !== true) return undefined;
|
|
const begin = Number(playUrl.tryBegin ?? playUrl.begin ?? playUrl.start ?? 0);
|
|
const end = Number(playUrl.tryEnd ?? playUrl.end);
|
|
if (!Number.isFinite(end) || end <= begin) return undefined;
|
|
const secs = end > 1000 ? (end - begin) / 1000 : end - begin;
|
|
return Math.round(secs);
|
|
}
|
|
|
|
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.map((a) => {
|
|
const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
|
|
const artist = a.singerName
|
|
?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
|
|
const coverUrl = id
|
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
|
|
: (a.albumPic ?? "");
|
|
return {
|
|
id,
|
|
name: a.albumName ?? a.title ?? "",
|
|
artist,
|
|
coverUrl,
|
|
songCount: a.song_count ?? a.songCount ?? 0,
|
|
platform: "qq" as const,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** QQ hands out http:// image URLs; the WebUI is often served over https. */
|
|
function httpsImage(url: unknown): string {
|
|
return typeof url === "string" ? url.replace(/^http:\/\//i, "https://") : "";
|
|
}
|
|
|
|
export function mapQqArtists(raw: any[] | null | undefined): Artist[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw
|
|
.map((a) => {
|
|
const id = String(a.singerMID ?? a.singer_mid ?? a.mid ?? a.singerID ?? a.singerId ?? "");
|
|
const mid = a.singerMID ?? a.singer_mid ?? a.mid;
|
|
return {
|
|
id,
|
|
name: a.singerName ?? a.name ?? "",
|
|
// Search returns a 150px portrait; the MID builds the 500px one the
|
|
// artist page wants, so prefer it and only fall back to the given URL.
|
|
avatarUrl: mid
|
|
? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg`
|
|
: httpsImage(a.singerPic ?? a.pic),
|
|
songCount: a.songNum ?? undefined,
|
|
albumCount: a.albumNum ?? undefined,
|
|
platform: "qq" as const,
|
|
};
|
|
})
|
|
.filter((a) => a.id && a.name);
|
|
}
|
|
|
|
function computeGtk(pSkey: string): number {
|
|
let hash = 5381;
|
|
for (let i = 0; i < pSkey.length; i++) {
|
|
hash = (hash + (hash << 5) + pSkey.charCodeAt(i)) | 0;
|
|
}
|
|
return hash & 0x7fffffff;
|
|
}
|
|
|
|
export class QQMusicProvider implements MusicProvider {
|
|
readonly platform = "qq" as const;
|
|
private api: AxiosInstance;
|
|
private cookie = "";
|
|
private quality = "exhigh";
|
|
private radarPage = 1;
|
|
|
|
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 } : {};
|
|
}
|
|
|
|
private get directCookieHeaders(): Record<string, string> {
|
|
return this.cookie ? { Cookie: this.cookie } : {};
|
|
}
|
|
|
|
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
|
|
const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie);
|
|
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
|
|
return {
|
|
comm: {
|
|
ct: 24,
|
|
cv: 4747474,
|
|
platform: "yqq.json",
|
|
uin: uinMatch ? uinMatch[1] : "0",
|
|
g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381,
|
|
format: "json",
|
|
inCharset: "utf-8",
|
|
outCharset: "utf-8",
|
|
notice: 0,
|
|
need_new_code: 1,
|
|
},
|
|
req_0: { module, method, param },
|
|
};
|
|
}
|
|
|
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
|
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
|
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
|
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
|
const primary = await this.searchViaMusicuFcg(query, limit, offset);
|
|
if (primary) return primary;
|
|
|
|
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
|
// no playlist support). Kept as redundancy.
|
|
return this.searchViaClientSearchCp(query, limit, offset);
|
|
}
|
|
|
|
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
|
*
|
|
* Two upstream API changes (2026-05) required fixes:
|
|
* 1. Omit `searchid` — its presence now causes all lists to be empty.
|
|
* 2. `num_per_page` >= 10 — lower values return empty.
|
|
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
|
private async searchViaMusicuFcg(
|
|
query: string,
|
|
limit: number,
|
|
offset = 0
|
|
): Promise<SearchResult | null> {
|
|
try {
|
|
// num_per_page must stay >= 10 (lower values return empty). It is now
|
|
// limit-driven for ALL three lists (albums/playlists were hardcoded to
|
|
// 10). page_num is the offset cursor; the web always requests in
|
|
// limit-aligned pages so offset is a multiple of limit.
|
|
const numPerPage = Math.max(10, Math.min(limit, 50));
|
|
const pageNum = Math.floor(offset / limit) + 1;
|
|
const reqData = JSON.stringify({
|
|
req_0: {
|
|
module: "music.search.SearchCgiService",
|
|
method: "DoSearchForQQMusicDesktop",
|
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
|
|
},
|
|
req_album: {
|
|
module: "music.search.SearchCgiService",
|
|
method: "DoSearchForQQMusicDesktop",
|
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
|
|
},
|
|
req_playlist: {
|
|
module: "music.search.SearchCgiService",
|
|
method: "DoSearchForQQMusicDesktop",
|
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
|
|
},
|
|
// search_type 1 = singers. Folded into the same batch so artist search
|
|
// costs no extra round-trip.
|
|
req_artist: {
|
|
module: "music.search.SearchCgiService",
|
|
method: "DoSearchForQQMusicDesktop",
|
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 1 },
|
|
},
|
|
});
|
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
|
params: { format: "json", data: reqData },
|
|
});
|
|
|
|
const songList: any[] =
|
|
res.data?.req_0?.data?.body?.song?.list ?? [];
|
|
const artistList: any[] =
|
|
res.data?.req_artist?.data?.body?.singer?.list ?? [];
|
|
// Only fall back to the older client_search_cp path when the batch came
|
|
// back completely empty — an artist-only hit is a real result.
|
|
if (songList.length === 0 && artistList.length === 0) return null;
|
|
|
|
const songs = mapQqSongs(songList);
|
|
|
|
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
|
const albums = mapQqAlbums(albumList);
|
|
|
|
const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
|
|
const playlists: Playlist[] = playlistList.map((p: any) => ({
|
|
id: String(p.dissid ?? p.id ?? ""),
|
|
name: p.dissname ?? p.title ?? "",
|
|
coverUrl: p.imgurl ?? p.logo ?? "",
|
|
songCount: p.songnum ?? p.song_count ?? 0,
|
|
platform: "qq" as const,
|
|
}));
|
|
|
|
return { songs, playlists, albums, artists: mapQqArtists(artistList) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
|
private async searchViaClientSearchCp(
|
|
query: string,
|
|
limit: number,
|
|
offset = 0
|
|
): Promise<SearchResult> {
|
|
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
|
|
// offset is a multiple of limit.
|
|
const page = Math.floor(offset / limit) + 1;
|
|
const songParams = {
|
|
w: query,
|
|
format: "json",
|
|
p: page,
|
|
n: Math.min(limit, 50),
|
|
type: 0,
|
|
cr: 1,
|
|
};
|
|
const albumParams = {
|
|
w: query,
|
|
format: "json",
|
|
p: page,
|
|
n: 5,
|
|
t: 8,
|
|
cr: 1,
|
|
};
|
|
|
|
const [songRes, albumRes] = await Promise.allSettled([
|
|
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
|
|
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
|
|
]);
|
|
|
|
const songList: any[] =
|
|
songRes.status === "fulfilled"
|
|
? (songRes.value.data?.data?.song?.list ?? [])
|
|
: [];
|
|
|
|
const songs = mapQqSongs(songList);
|
|
|
|
const albumList: any[] =
|
|
albumRes.status === "fulfilled"
|
|
? (albumRes.value.data?.data?.album?.list ?? [])
|
|
: [];
|
|
const albums = mapQqAlbums(albumList);
|
|
|
|
return { songs, playlists: [], albums };
|
|
}
|
|
|
|
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | 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 { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
|
|
} 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 { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Batch-check which song mids are actually streamable. QQ playlists
|
|
* (especially collected ones) frequently contain a majority of songs
|
|
* that return result=104003 ("no copyright/region restricted") for the
|
|
* current user — a sequential retry loop wastes time guessing.
|
|
*
|
|
* The wrapper's /getMusicPlay accepts a comma-separated songmid list
|
|
* and resolves all of them in a single upstream call. We chunk to keep
|
|
* the URL well under typical 8KB query-string limits and to keep per-
|
|
* request latency bounded (~2-3s per 100 mids).
|
|
*
|
|
* Returns:
|
|
* - non-null Set: authoritative result. Empty Set means all songs are
|
|
* unplayable; non-empty means filter to those mids.
|
|
* - null: every chunk failed. Caller should fall back to sequential
|
|
* retry rather than treating as "all unplayable".
|
|
*/
|
|
async getPlayableSongIds(songIds: string[]): Promise<Set<string> | null> {
|
|
if (songIds.length === 0) return new Set();
|
|
|
|
const CHUNK = 100; // ~14 chars/mid * 100 + commas ≈ 1.5KB
|
|
const playable = new Set<string>();
|
|
let allChunksFailed = true;
|
|
for (let i = 0; i < songIds.length; i += CHUNK) {
|
|
const slice = songIds.slice(i, i + CHUNK);
|
|
try {
|
|
const res = await this.api.get("/getMusicPlay", {
|
|
params: { songmid: slice.join(","), quality: this.quality, ...this.cookieParams },
|
|
});
|
|
const playUrlMap: Record<string, { url?: string }> | undefined =
|
|
res.data?.data?.playUrl;
|
|
if (!playUrlMap) continue; // chunk-level failure, try next
|
|
allChunksFailed = false;
|
|
for (const [mid, info] of Object.entries(playUrlMap)) {
|
|
if (info?.url) playable.add(mid);
|
|
}
|
|
} catch {
|
|
// chunk-level failure — keep going so a transient error on one
|
|
// chunk doesn't poison the whole batch.
|
|
}
|
|
}
|
|
return allChunksFailed ? null : playable;
|
|
}
|
|
|
|
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 musicu.fcg fallback
|
|
}
|
|
|
|
// Fallback: fetch metadata via u.y.qq.com/cgi-bin/musicu.fcg. The bundled
|
|
// library's /getSongInfo route is broken (upstream code 500001), so the
|
|
// primary try above almost always falls through here for QQ. Without this,
|
|
// play-by-id songs carry an empty name → the TS nickname / now-playing
|
|
// message renders as "♪ 正在播放: - []". This endpoint (same host/shape
|
|
// as search) reliably returns full track_info without requiring login.
|
|
const detail = await this.fetchSongDetailViaMusicu(songId);
|
|
if (detail) return detail;
|
|
|
|
// 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",
|
|
};
|
|
}
|
|
|
|
/** Fetch a single song's metadata via u.y.qq.com/cgi-bin/musicu.fcg
|
|
* (module music.pf_song_detail_svr / get_song_detail_yqq). This mirrors the
|
|
* search path's host + request shape and, unlike the library's /getSongInfo,
|
|
* actually works against QQ's current API. Returns null on any failure so the
|
|
* caller can fall back to the minimal stub. */
|
|
private async fetchSongDetailViaMusicu(songId: string): Promise<Song | null> {
|
|
try {
|
|
const reqData = JSON.stringify({
|
|
req_0: {
|
|
module: "music.pf_song_detail_svr",
|
|
method: "get_song_detail_yqq",
|
|
param: { song_mid: songId, song_type: 0 },
|
|
},
|
|
});
|
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
|
params: { format: "json", data: reqData },
|
|
});
|
|
const t = res.data?.req_0?.data?.track_info;
|
|
if (!t) return null;
|
|
const albumMid = t.album?.mid ?? t.album?.pmid ?? "";
|
|
return {
|
|
id: String(t.mid ?? t.id ?? songId),
|
|
name: t.name ?? t.title ?? "",
|
|
artist: (t.singer ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
|
|
album: t.album?.name ?? t.album?.title ?? "",
|
|
duration: t.interval ?? 0,
|
|
coverUrl: albumMid
|
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
|
: "",
|
|
platform: "qq",
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 mapQqSongs(cdlist[0].songlist ?? []);
|
|
}
|
|
|
|
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
|
const res = await this.api.get("/getSongListDetail", {
|
|
params: { disstid: playlistId, ...this.cookieParams },
|
|
});
|
|
const cd = res.data?.response?.cdlist?.[0];
|
|
if (!cd) return null;
|
|
return {
|
|
id: String(cd.disstid ?? cd.dissid ?? ""),
|
|
name: cd.dissname ?? "",
|
|
description: cd.desc ?? "",
|
|
coverUrl: cd.logo ?? "",
|
|
songCount: cd.songnum ?? cd.total_song_num ?? 0,
|
|
};
|
|
}
|
|
|
|
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 },
|
|
});
|
|
const response = res.data?.response;
|
|
const list = response?.data?.list;
|
|
if (
|
|
(res.data?.code != null && Number(res.data.code) !== 0) ||
|
|
(response?.code != null && Number(response.code) !== 0) ||
|
|
!Array.isArray(list) || !list.every(isQqSongRow)
|
|
) {
|
|
throw new Error("QQ album-song lookup failed");
|
|
}
|
|
return mapQqSongs(list);
|
|
}
|
|
|
|
/** music.web_singer_info_svr / get_singer_detail_info — singer info plus up
|
|
* to `num` of their hottest songs (sort 5 = popularity). Returns null on any
|
|
* failure so callers can degrade instead of throwing. */
|
|
private async fetchSingerDetail(singerMid: string, num: number): Promise<any | null> {
|
|
try {
|
|
const reqData = JSON.stringify({
|
|
req_0: {
|
|
module: "music.web_singer_info_svr",
|
|
method: "get_singer_detail_info",
|
|
param: {
|
|
singermid: singerMid,
|
|
sort: 5,
|
|
num: Math.max(1, Math.min(num, 50)),
|
|
begin: 0,
|
|
},
|
|
},
|
|
});
|
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
|
params: { format: "json", data: reqData },
|
|
});
|
|
const response = res.data?.req_0;
|
|
const data = response?.data;
|
|
if (
|
|
(res.data?.code != null && Number(res.data.code) !== 0) ||
|
|
(response?.code != null && Number(response.code) !== 0) ||
|
|
typeof data?.singer_info?.name !== "string" ||
|
|
!data.singer_info.name ||
|
|
!Array.isArray(data.songlist) || !data.songlist.every(isQqSongRow)
|
|
) return null;
|
|
return data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getArtistDetail(artistId: string): Promise<ArtistDetail | null> {
|
|
const data = await this.fetchSingerDetail(artistId, 1);
|
|
if (!data) return null;
|
|
const info = data.singer_info ?? {};
|
|
const mid = String(info.mid ?? artistId);
|
|
if (!mid) return null;
|
|
return {
|
|
id: mid,
|
|
name: info.name ?? "",
|
|
avatarUrl: `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg`,
|
|
aliases: info.other_name ? [String(info.other_name)] : [],
|
|
songCount: data.total_song ?? undefined,
|
|
albumCount: data.total_album ?? undefined,
|
|
platform: "qq" as const,
|
|
description: data.singer_brief ?? "",
|
|
};
|
|
}
|
|
|
|
async getArtistSongs(artistId: string, limit = 50): Promise<Song[]> {
|
|
const data = await this.fetchSingerDetail(artistId, limit);
|
|
return mapQqSongs(data?.songlist ?? []);
|
|
}
|
|
|
|
/**
|
|
* One page of the singer's full catalogue. get_singer_detail_info ignores its
|
|
* `begin` parameter (begin=0/50/100 all return the same top 50 — verified
|
|
* 2026-10) and QQ has no working singer-song-list endpoint, so the catalogue
|
|
* is assembled from every album the singer owns: the hot 50 first (they rank
|
|
* best) followed by the album tracks, de-duplicated by songmid.
|
|
*/
|
|
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
|
|
const catalogue = await this.buildArtistCatalog(artistId);
|
|
const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
|
|
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(Math.trunc(limit) || 50, 100)) : 50;
|
|
const songs = catalogue.songs.slice(safeOffset, safeOffset + safeLimit);
|
|
return {
|
|
songs,
|
|
total: catalogue.total,
|
|
hasMore: safeOffset + songs.length < catalogue.total || catalogue.incomplete,
|
|
};
|
|
}
|
|
|
|
/** Memoised full catalogues, keyed by singer MID (see ARTIST_CATALOG_TTL_MS). */
|
|
private artistCatalog = new Map<string, { at: number; catalogue: ArtistCatalog }>();
|
|
|
|
private async buildArtistCatalog(artistId: string): Promise<ArtistCatalog> {
|
|
const cached = this.artistCatalog.get(artistId);
|
|
if (cached && Date.now() - cached.at < ARTIST_CATALOG_TTL_MS) return cached.catalogue;
|
|
|
|
const merged: Song[] = [];
|
|
const seen = new Set<string>();
|
|
const push = (song: Song) => {
|
|
if (!song.id || seen.has(song.id)) return;
|
|
seen.add(song.id);
|
|
if (merged.length < ARTIST_CATALOG_MAX_SONGS) merged.push(song);
|
|
};
|
|
|
|
const hot = await this.fetchSingerDetail(artistId, 50);
|
|
for (const song of mapQqSongs(hot?.songlist)) push(song);
|
|
const detail = await this.fetchSingerDetail(artistId, 1);
|
|
const name = detail?.singer_info?.name;
|
|
let failed = !hot || !detail;
|
|
let complete = false;
|
|
const albumIds = new Set<string>();
|
|
const searchAlbumIds = new Set<string>();
|
|
if (name) {
|
|
for (let page = 1; page <= ARTIST_ALBUM_MAX_PAGES && merged.length < ARTIST_CATALOG_MAX_SONGS; page++) {
|
|
const list = (await this.searchArtistAlbums(name, page, 50)) ?? (await this.searchArtistAlbums(name, page, 50));
|
|
if (list === null) { failed = true; break; }
|
|
if (list.length === 0) { complete = true; break; }
|
|
const batchIds: string[] = [];
|
|
let freshSearchEntries = 0;
|
|
for (const entry of list) {
|
|
const mid = String(entry?.albumMID ?? entry?.album_mid ?? "");
|
|
if (!mid) { failed = true; continue; }
|
|
if (!searchAlbumIds.has(mid)) { searchAlbumIds.add(mid); freshSearchEntries++; }
|
|
if (isArtistAlbum(entry, artistId) && !albumIds.has(mid)) {
|
|
albumIds.add(mid);
|
|
batchIds.push(mid);
|
|
}
|
|
}
|
|
// Repeated pages cannot prove exhaustion, but must not loop forever.
|
|
if (freshSearchEntries === 0) { failed = true; break; }
|
|
let fetchedAlbums = 0;
|
|
for (let i = 0; i < batchIds.length && merged.length < ARTIST_CATALOG_MAX_SONGS; i += ARTIST_ALBUM_CONCURRENCY) {
|
|
const batch = batchIds.slice(i, i + ARTIST_ALBUM_CONCURRENCY);
|
|
const lists = await Promise.all(batch.map((mid) =>
|
|
this.getAlbumSongs(mid).catch(() => { failed = true; return [] as Song[]; })
|
|
));
|
|
fetchedAlbums += batch.length;
|
|
for (const songs of lists) for (const song of songs) push(song);
|
|
}
|
|
if (list.length < 50 && fetchedAlbums === batchIds.length && seen.size <= ARTIST_CATALOG_MAX_SONGS) { complete = true; break; }
|
|
}
|
|
}
|
|
const incomplete = failed || !complete;
|
|
const reported = Math.max(0, ...[hot?.total_song, detail?.total_song].map((n) => Number.isFinite(Number(n)) ? Math.trunc(Number(n)) : 0));
|
|
const catalogue: ArtistCatalog = {
|
|
songs: merged,
|
|
total: incomplete ? Math.max(seen.size, reported, merged.length === ARTIST_CATALOG_MAX_SONGS ? ARTIST_CATALOG_MAX_SONGS + 1 : 0) : merged.length,
|
|
incomplete,
|
|
};
|
|
// Cache complete catalogues and intentional 500-song truncation only.
|
|
// Failure, repeated pages and an exhausted scan budget must remain retryable.
|
|
if (!failed && (complete || merged.length === ARTIST_CATALOG_MAX_SONGS)) {
|
|
if (this.artistCatalog.size >= ARTIST_CATALOG_MAX_ENTRIES) {
|
|
const oldest = this.artistCatalog.keys().next().value;
|
|
if (oldest !== undefined) this.artistCatalog.delete(oldest);
|
|
}
|
|
this.artistCatalog.set(artistId, { at: Date.now(), catalogue });
|
|
}
|
|
return catalogue;
|
|
}
|
|
|
|
/** search_type=2 album search for a singer name — raw entries, null on failure. */
|
|
private async searchArtistAlbums(
|
|
name: string,
|
|
pageNum: number,
|
|
numPerPage: number
|
|
): Promise<any[] | null> {
|
|
try {
|
|
const reqData = JSON.stringify({
|
|
req_album: {
|
|
module: "music.search.SearchCgiService",
|
|
method: "DoSearchForQQMusicDesktop",
|
|
param: {
|
|
query: name,
|
|
num_per_page: Math.max(10, Math.min(numPerPage, 50)),
|
|
page_num: pageNum,
|
|
search_type: 2,
|
|
},
|
|
},
|
|
});
|
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
|
params: { format: "json", data: reqData },
|
|
});
|
|
const response = res.data?.req_album;
|
|
const list = response?.data?.body?.album?.list;
|
|
if (
|
|
(res.data?.code != null && Number(res.data.code) !== 0) ||
|
|
(response?.code != null && Number(response.code) !== 0) ||
|
|
!Array.isArray(list)
|
|
) return null;
|
|
return list;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getArtistAlbums(artistId: string, limit = 20): Promise<Album[]> {
|
|
// QQ has no working "albums for this singer MID" endpoint: the homepage tab
|
|
// API returns a null AlbumList and music.web_singer_info_svr/get_singer_album
|
|
// returns an empty list even with a logged-in cookie (verified 2026-10).
|
|
// The album shelf is therefore built from the album search for the singer's
|
|
// name, filtered down to entries whose singerMID actually matches.
|
|
const detail = await this.fetchSingerDetail(artistId, 1);
|
|
const name = detail?.singer_info?.name;
|
|
if (!name) return [];
|
|
const list = (await this.searchArtistAlbums(name, 1, limit)) ?? [];
|
|
const mine = list.filter((a: any) => isArtistAlbum(a, artistId));
|
|
return mapQqAlbums(mine).slice(0, limit);
|
|
}
|
|
|
|
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;
|
|
// Reset radar pagination so a re-login (different account) starts from the
|
|
// first page rather than inheriting the previous account's cursor.
|
|
this.radarPage = 1;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
async getDailyRecommendSongs(): Promise<Song[]> {
|
|
// QQ has no per-user daily list; use newsong.NewSongServer (新歌速递)
|
|
// as the closest analogue. Returns ~20 newly-released songs.
|
|
try {
|
|
const res = await this.api.get("/getNewSongs", {
|
|
params: { ...this.cookieParams },
|
|
});
|
|
const list: any[] = res.data?.response?.new_song?.data?.songlist ?? [];
|
|
return list.map((s: any) => ({
|
|
id: String(s.mid ?? s.id),
|
|
name: s.title ?? s.name ?? "",
|
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
|
album: s.album?.name ?? s.album?.title ?? "",
|
|
duration: s.interval ?? 0,
|
|
coverUrl: s.album?.mid
|
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
|
: "",
|
|
platform: "qq",
|
|
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getPersonalFm(): Promise<Song[]> {
|
|
const radarSongs = await this.getRadarRecommendSongs();
|
|
if (radarSongs.length > 0) return radarSongs;
|
|
return this.getGuessRecommendSongs();
|
|
}
|
|
|
|
private async getRadarRecommendSongs(): Promise<Song[]> {
|
|
try {
|
|
const page = this.radarPage;
|
|
const res = await qqMusicuApi.post(
|
|
"/cgi-bin/musicu.fcg",
|
|
this.buildMusicuPayload(
|
|
"music.recommend.TrackRelationServer",
|
|
"GetRadarSong",
|
|
{
|
|
Page: page,
|
|
ReqType: 0,
|
|
FavSongs: [],
|
|
EntranceSongs: [],
|
|
}
|
|
),
|
|
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
|
);
|
|
const tracks = (res.data?.req_0?.data?.VecSongs ?? [])
|
|
.map((item: any) => item?.Track)
|
|
.filter(Boolean);
|
|
const songs = mapQqSongs(tracks);
|
|
if (songs.length > 0) {
|
|
this.radarPage = page + 1;
|
|
}
|
|
return songs;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async getGuessRecommendSongs(): Promise<Song[]> {
|
|
try {
|
|
const res = await qqMusicuApi.post(
|
|
"/cgi-bin/musicu.fcg",
|
|
this.buildMusicuPayload(
|
|
"music.radioProxy.MbTrackRadioSvr",
|
|
"get_radio_track",
|
|
{
|
|
id: 99,
|
|
num: 5,
|
|
from: 0,
|
|
scene: 0,
|
|
song_ids: [],
|
|
}
|
|
),
|
|
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
|
);
|
|
return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getUserPlaylists(): Promise<Playlist[]> {
|
|
if (!this.cookie) return [];
|
|
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
|
const uin = uinMatch ? uinMatch[1] : "";
|
|
if (!uin) return [];
|
|
|
|
// Created and collected playlists come from two separate QQ endpoints.
|
|
// Run them in parallel and concatenate (created first, then collected),
|
|
// matching the order shown in the QQ Music desktop app.
|
|
const [created, collected] = await Promise.all([
|
|
this.fetchCreatedPlaylists(uin),
|
|
this.fetchCollectedPlaylists(uin),
|
|
]);
|
|
return [...created, ...collected];
|
|
}
|
|
|
|
private async fetchCreatedPlaylists(uin: string): Promise<Playlist[]> {
|
|
try {
|
|
const res = await this.api.get("/user/getUserPlaylists", {
|
|
params: { uin, ...this.cookieParams },
|
|
});
|
|
if (res.data?.response?.code !== 0) return [];
|
|
return (res.data?.response?.data?.playlists ?? []).map((p: any) => {
|
|
// fcg_get_profile_homepage returns title/picurl/subtitle ("X首 Y次播放").
|
|
const subtitle: string = p.subtitle ?? "";
|
|
const songCountFromSubtitle = parseInt(subtitle.match(/(\d+)\s*首/)?.[1] ?? "0", 10);
|
|
return {
|
|
id: String(p.dissid ?? p.id ?? ""),
|
|
name: p.title ?? p.dissname ?? p.name ?? "",
|
|
coverUrl: p.picurl ?? p.imgurl ?? p.coverUrl ?? "",
|
|
songCount: p.song_count ?? p.listennum ?? songCountFromSubtitle,
|
|
platform: "qq",
|
|
};
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async fetchCollectedPlaylists(uin: string): Promise<Playlist[]> {
|
|
// c.y.qq.com fav endpoint: reqtype=3 returns collected playlists (cdlist).
|
|
// Requires g_tk derived from the p_skey cookie.
|
|
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
|
|
if (!pSkeyMatch) return [];
|
|
const gtk = computeGtk(pSkeyMatch[1]);
|
|
|
|
const PAGE_SIZE = 30;
|
|
const MAX_PAGES = 10; // 300-playlist hard cap; should cover any sane user
|
|
const all: Playlist[] = [];
|
|
try {
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const sin = page * PAGE_SIZE;
|
|
const ein = sin + PAGE_SIZE - 1;
|
|
const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", {
|
|
params: {
|
|
ct: 20,
|
|
cid: 205360956,
|
|
userid: uin,
|
|
reqtype: 3,
|
|
sin,
|
|
ein,
|
|
g_tk: gtk,
|
|
format: "json",
|
|
},
|
|
headers: { Cookie: this.cookie },
|
|
});
|
|
if (res.data?.code !== 0) break;
|
|
const list: any[] = res.data?.data?.cdlist ?? [];
|
|
for (const p of list) {
|
|
all.push({
|
|
id: String(p.dissid ?? ""),
|
|
name: p.dissname ?? "",
|
|
coverUrl: p.logo ?? "",
|
|
songCount: p.songnum ?? 0,
|
|
platform: "qq",
|
|
});
|
|
}
|
|
// Stop when upstream signals no more pages, or when this page is
|
|
// short (also indicates end). has_more is the canonical signal.
|
|
const hasMore = res.data?.data?.has_more === 1 || res.data?.data?.has_more === true;
|
|
if (!hasMore || list.length < PAGE_SIZE) break;
|
|
}
|
|
} catch {
|
|
// Return whatever we got so far on partial failure rather than dropping
|
|
// earlier pages.
|
|
}
|
|
return all;
|
|
}
|
|
}
|