Files
music-hub/server/src/core/providers/qq/index.ts
T
Jocay 21945cc54f feat: 声匣 —— 网易云 / QQ 音乐自托管下载服务
由 163-music-dl CLI 重构为 Web 服务:
- 下载流水线、歌词与标签逻辑沿用旧版(node-id3 / ffmpeg)
- 网易云(chksz 接口 + music.163.com 元数据)与 QQ 音乐(VIP Cookie)provider
- QQ 扫码登录与 musickey 自动续期
- SQLite 持久化的下载队列,SSE 实时进度
- 曲库工具(M3U / ZIP / 回收站 / 补全标签)、历史统计与导出、访问密码
- Vite 8 + Vue 3 前端,浅色 / 深色
- Docker 部署配置(飞牛 NAS)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:05:30 +08:00

356 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<Record<QualityKey, { prefix: string; ext: string; size: (f: any) => 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<AlbumInfo | null>(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<Paged<TrackSummary>> {
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<any | null> {
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<AlbumInfo | null> {
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<AlbumDetail | null> {
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<PlaylistDetail | null> {
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<ArtistDetail | null> {
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<Lyric | null> {
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<TrackMeta | null> {
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<ResolvedAudio> {
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;
}
}