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>
This commit is contained in:
Jocay
2026-10-10 22:05:30 +08:00
commit 21945cc54f
73 changed files with 12507 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
// 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;
}
}