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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user