// QQ 音乐 provider:全部走 musicu.fcg;VIP 音源依赖已登录 Cookie import { fallbackChain, type QualityKey } from '../../quality.ts'; import { FatalError, type AlbumDetail, type AlbumSummary, type ArtistDetail, type LinkRef, type Lyric, type MusicProvider, type Paged, type PlaylistDetail, type ResolvedAudio, type TrackMeta, type TrackSummary, } from '../../types.ts'; import { joinNames, TtlCache } from '../../util.ts'; import { QQClient, type QQCredentials } from './client.ts'; /** 统一音质 → QQ 文件前缀。母带 / 全景声(AI00 / Q000)为加密格式,暂不支持 */ const FILE_TYPES: Partial number; br: number }>> = { hires: { prefix: 'RS01', ext: 'flac', size: (f) => f.size_hires, br: 0 }, lossless: { prefix: 'F000', ext: 'flac', size: (f) => f.size_flac, br: 0 }, high: { prefix: 'M800', ext: 'mp3', size: (f) => f.size_320mp3, br: 320 }, standard: { prefix: 'M500', ext: 'mp3', size: (f) => f.size_128mp3, br: 128 }, }; const COMPILATION_NAMES = ['群星', 'variousartists', 'variousartist', 'va']; export function qqCover(albumMid: string | undefined, size = 800): string | undefined { return albumMid ? `https://y.gtimg.cn/music/photo_new/T002R${size}x${size}M000${albumMid}.jpg` : undefined; } function singerCover(singerMid: string | undefined): string | undefined { return singerMid ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${singerMid}.jpg` : undefined; } function availableQualities(file: any): QualityKey[] { if (!file) return []; return (Object.keys(FILE_TYPES) as QualityKey[]).filter((q) => FILE_TYPES[q]!.size(file) > 0); } function decodeLyric(value: string | undefined): string | undefined { if (!value) return undefined; const text = /^[A-Za-z0-9+/=\s]+$/.test(value) ? Buffer.from(value, 'base64').toString('utf-8') : value; return text.trim() ? text : undefined; } function parseDate(value: string | undefined): { year?: number; date?: string } { const m = value?.match(/^(\d{4})(?:-(\d{2})-(\d{2}))?/); if (!m) return {}; return { year: Number(m[1]), date: m[2] ? `${m[1]}-${m[2]}-${m[3]}` : undefined }; } interface AlbumInfo { mid: string; name: string; singers: Array<{ mid: string; name: string }>; publishDate?: string; company?: string; genre?: string; type?: string; description?: string; total: number; songs: any[]; } export class QQProvider implements MusicProvider { readonly source = 'qq' as const; readonly qualities = ['standard', 'high', 'lossless', 'hires'] as const; readonly client: QQClient; private readonly albumCache = new TtlCache(10 * 60 * 1000); constructor(creds: QQCredentials) { this.client = new QQClient(creds); } // ---- 列表数据 ---- private toSummary(t: any): TrackSummary { return { source: this.source, id: t.mid, name: t.title || t.name, artists: joinNames(t.singer), artistRefs: (t.singer || []).map((s: any) => ({ id: s.mid, name: s.name })), album: t.album?.title || t.album?.name || '', albumId: t.album?.mid || undefined, cover: qqCover(t.album?.mid, 300), duration: t.interval ? t.interval * 1000 : undefined, trackNo: t.index_album || undefined, qualities: availableQualities(t.file), vip: t.pay?.pay_play === 1, }; } async search(keyword: string, { page = 1, limit = 30 } = {}): Promise> { const data = await this.client.call({ module: 'music.search.SearchCgiService', method: 'DoSearchForQQMusicDesktop', param: { query: keyword, num_per_page: limit, page_num: page, search_type: 0 }, }); const list: any[] = data?.body?.song?.list || []; return { items: list.map((t) => this.toSummary(t)), total: data?.meta?.sum ?? list.length, page, limit }; } private async fetchSong(mid: string, signal?: AbortSignal): Promise { const data = await this.client.call( { module: 'music.pf_song_detail_svr', method: 'get_song_detail_yqq', param: { song_type: 0, song_mid: mid } }, signal, ); return data?.track_info?.mid ? data : null; } private fetchAlbumInfo(albumMid: string): Promise { return this.albumCache.getOrSet(albumMid, async () => { const page = 100; const res = await this.client.musicu({ a: { module: 'music.musichallAlbum.AlbumInfoServer', method: 'GetAlbumDetail', param: { albumMid } }, s: { module: 'music.musichallAlbum.AlbumSongList', method: 'GetAlbumSongList', param: { albumMid, begin: 0, num: page, order: 2 } }, }); const basic = res.a.data?.basicInfo; if (res.a.code !== 0 || !basic?.albumName) return null; const songs: any[] = (res.s.data?.songList || []).map((it: any) => it.songInfo); const total: number = res.s.data?.totalNum ?? songs.length; for (let begin = songs.length; begin < total && begin < 2000; begin += page) { const more = await this.client.call({ module: 'music.musichallAlbum.AlbumSongList', method: 'GetAlbumSongList', param: { albumMid, begin, num: page, order: 2 }, }); const batch = (more?.songList || []).map((it: any) => it.songInfo); if (!batch.length) break; songs.push(...batch); } return { mid: basic.albumMid, name: basic.albumName, singers: (res.a.data?.singer?.singerList || []).map((s: any) => ({ mid: s.mid, name: s.name })), publishDate: basic.publishDate || undefined, company: res.a.data?.company?.name || undefined, genre: basic.genreNew || basic.genre || undefined, type: basic.albumType || undefined, description: basic.desc || undefined, total, songs, }; }); } async getAlbum(id: string): Promise { const a = await this.fetchAlbumInfo(id); if (!a) return null; return { source: this.source, id: a.mid, name: a.name, artist: joinNames(a.singers), artistRefs: a.singers.map((s) => ({ id: s.mid, name: s.name })), cover: qqCover(a.mid), publishTime: a.publishDate ? Date.parse(a.publishDate) : undefined, size: a.total, type: a.type, company: a.company, description: a.description, tracks: a.songs.map((t) => this.toSummary(t)), }; } async getPlaylist(id: string): Promise { const page = 1000; const songs: any[] = []; let info: any = null; let total = 0; for (let begin = 0; begin === 0 || (begin < total && begin < 10000); begin += page) { const data = await this.client.call({ module: 'music.srfDissInfo.aiDissInfo', method: 'uniform_get_Dissinfo', param: { disstid: Number(id), userinfo: 1, tag: 1, orderlist: 1, song_begin: begin, song_num: page, onlysonglist: begin > 0 ? 1 : 0, enc_host_uin: '' }, }); if (begin === 0) { info = data?.dirinfo; total = data?.total_song_num ?? 0; } const batch: any[] = data?.songlist || []; if (!batch.length) break; songs.push(...batch); } if (!info?.title && !songs.length) return null; return { source: this.source, id, name: info?.title || `歌单 ${id}`, cover: info?.picurl || undefined, creator: info?.host_nick || undefined, description: info?.desc || undefined, trackCount: total || songs.length, tracks: songs.map((t) => this.toSummary(t)), }; } async getArtist(id: string): Promise { const res = await this.client.musicu({ d: { module: 'music.musichallSinger.SingerInfoInter', method: 'GetSingerDetail', param: { singer_mids: [id], pic: 1, group_singer: 0, wiki_singer: 0, ex_singer: 1 } }, s: { module: 'musichall.song_list_server', method: 'GetSingerSongList', param: { singerMid: id, begin: 0, num: 100, order: 1 } }, a: { module: 'music.musichallAlbum.AlbumListServer', method: 'GetAlbumList', param: { singerMid: id, order: 0, begin: 0, num: 100, songNumTag: 0, singerID: 0 } }, }); const singer = res.d.data?.singer_list?.[0]; const name = singer?.basic_info?.name; if (!name) return null; return { source: this.source, id, name, avatar: singerCover(id), description: singer?.ex_info?.desc || undefined, tracks: (res.s.data?.songList || []).map((it: any) => this.toSummary(it.songInfo)), albums: (res.a.data?.albumList || []).map( (a: any): AlbumSummary => ({ source: this.source, id: a.albumMid, name: a.albumName, artist: a.singerName || name, cover: qqCover(a.albumMid, 300), publishTime: a.publishDate ? Date.parse(a.publishDate) : undefined, size: a.totalNum || undefined, type: a.albumType || undefined, }), ), }; } async getLyric(id: string): Promise { try { const data = await this.client.call({ module: 'music.musichallSong.PlayLyricInfo', method: 'GetPlayLyricInfo', param: { songMID: id, trans: 1, roma: 0, crypt: 0, ct: 19, cv: 0 }, }); const lrc = decodeLyric(data?.lyric); return lrc ? { lrc, tlyric: decodeLyric(data?.trans) } : null; } catch { return null; } } async getTrackMeta(id: string): Promise { const data = await this.fetchSong(id); if (!data) return null; const t = data.track_info; const info = data.info || {}; const albumMid: string | undefined = t.album?.mid || undefined; const meta: TrackMeta = { source: this.source, id: t.mid, name: t.title || t.name, artists: joinNames(t.singer), album: t.album?.title || t.album?.name || '', albumId: albumMid, cover: qqCover(albumMid), duration: t.interval ? t.interval * 1000 : undefined, trackNo: t.index_album || undefined, disc: albumMid ? String((t.index_cd || 0) + 1) : undefined, genre: info.genre?.content?.[0]?.value || undefined, publisher: info.company?.content?.[0]?.value || undefined, ...parseDate(info.pub_time?.content?.[0]?.value || t.time_public), }; if (albumMid) { const album = await this.fetchAlbumInfo(albumMid).catch(() => null); if (album) { const albumArtist = joinNames(album.singers); meta.albumArtist = albumArtist || undefined; meta.albumArtistPrimary = album.singers[0]?.name; meta.compilation = COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')) || undefined; meta.trackTotal = album.total || undefined; meta.genre ||= album.genre; meta.publisher ||= album.company; if (!meta.date) Object.assign(meta, parseDate(album.publishDate)); } } if (!meta.albumArtist) meta.albumArtist = meta.artists || undefined; return meta; } async resolveAudio(id: string, quality: QualityKey, signal?: AbortSignal): Promise { const auth = this.client.auth; const song = await this.fetchSong(id, signal); if (!song) throw new FatalError('歌曲不存在'); const t = song.track_info; const mediaMid: string = t.file?.media_mid || t.mid; const candidates = fallbackChain(quality) .map((q) => ({ q, type: FILE_TYPES[q] })) .filter((c): c is { q: QualityKey; type: NonNullable<(typeof FILE_TYPES)[QualityKey]> } => !!c.type && c.type.size(t.file) > 0); if (!candidates.length) throw new FatalError('该歌曲在 QQ 音乐没有可下载的音源'); const data = await this.client.call( { module: 'music.vkey.GetVkey', method: 'UrlGetVkey', param: { filename: candidates.map((c) => `${c.type.prefix}${mediaMid}.${c.type.ext}`), guid: '10000', songmid: candidates.map(() => id), songtype: candidates.map(() => 0), uin: auth?.uin ?? '0', loginflag: 1, platform: '20', }, }, signal, ); const sips: string[] = data?.sip || []; const host = (sips.find((s) => !s.startsWith('http://ws')) || sips[0] || 'https://isure.stream.qqmusic.qq.com/').replace(/^http:/, 'https:'); const infos: any[] = data?.midurlinfo || []; for (const c of candidates) { const info = infos.find((i) => i.filename?.startsWith(c.type.prefix)); if (!info?.purl) continue; const size = c.type.size(t.file); return { url: host + info.purl, ext: c.type.ext, quality: c.q, size, bitrate: c.type.br || (t.interval ? Math.round((size * 8) / t.interval / 1000) : undefined), name: t.title || t.name, artists: joinNames(t.singer), album: t.album?.title || t.album?.name || '', cover: qqCover(t.album?.mid), }; } throw new FatalError(auth ? '没有可用音源(可能需要更高等级会员或单独购买)' : '需要登录 QQ 音乐(在设置中导入 Cookie)'); } parseLink(input: string): LinkRef | null { const text = input.trim(); if (/^[0-9A-Za-z]{14}$/.test(text) && /^00/.test(text)) return { source: this.source, kind: 'track', id: text }; if (!/qq\.com/.test(text)) return null; const patterns: Array<[RegExp, LinkRef['kind']]> = [ [/songDetail\/([0-9A-Za-z]+)/, 'track'], [/[?&]songmid=([0-9A-Za-z]+)/, 'track'], [/albumDetail\/([0-9A-Za-z]+)/, 'album'], [/[?&]albummid=([0-9A-Za-z]+)/, 'album'], [/playlist\/(\d+)/, 'playlist'], [/[?&](?:id|disstid)=(\d+)/, 'playlist'], [/singer\/([0-9A-Za-z]+)/, 'artist'], ]; for (const [re, kind] of patterns) { const m = text.match(re); if (m) return { source: this.source, kind, id: m[1]! }; } return null; } }