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,37 @@
|
||||
// 输出目录与文件名规则
|
||||
// album: {音乐目录}/{第一位专辑艺人}/{专辑}/{NN}. {歌名}.ext —— 与 NAS 现有曲库结构一致
|
||||
// flat : {音乐目录}/{歌手} - {歌名}.ext —— 旧版单曲下载格式
|
||||
import path from 'node:path';
|
||||
import type { Layout } from '../config.ts';
|
||||
import { sanitize, sanitizePathSegment } from './util.ts';
|
||||
|
||||
export interface LayoutInput {
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
albumArtistPrimary?: string;
|
||||
trackNo?: number;
|
||||
trackTotal?: number;
|
||||
}
|
||||
|
||||
export interface Target {
|
||||
dir: string;
|
||||
baseName: string; // 不含扩展名
|
||||
isAlbumDir: boolean;
|
||||
}
|
||||
|
||||
export function resolveTarget(musicDir: string, layout: Layout, input: LayoutInput): Target {
|
||||
const title = input.name || '未知歌曲';
|
||||
if (layout === 'flat') {
|
||||
return { dir: musicDir, baseName: sanitize(`${input.artists} - ${title}`), isAlbumDir: false };
|
||||
}
|
||||
const firstArtist = input.artists.split(' / ')[0];
|
||||
const artistDir = sanitizePathSegment(input.albumArtistPrimary || firstArtist, '未知歌手');
|
||||
const albumDir = sanitizePathSegment(input.album, '未知专辑');
|
||||
let baseName = title;
|
||||
if (input.trackNo) {
|
||||
const width = Math.max(2, String(input.trackTotal || 0).length);
|
||||
baseName = `${String(input.trackNo).padStart(width, '0')}. ${title}`;
|
||||
}
|
||||
return { dir: path.join(musicDir, artistDir, albumDir), baseName: sanitize(baseName), isAlbumDir: true };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { formatPersonNames } from './util.ts';
|
||||
|
||||
/** 按时间轴把翻译歌词插到原文对应行之后 */
|
||||
export function mergeLrc(lrc: string | undefined, tlyric: string | undefined): string {
|
||||
if (!lrc) return '';
|
||||
if (!tlyric) return lrc;
|
||||
|
||||
const transMap = new Map<string, string>();
|
||||
for (const line of tlyric.split('\n')) {
|
||||
const match = line.match(/^\[(\d+:\d+[.:]\d+)\](.*)/);
|
||||
if (match && match[2]!.trim()) transMap.set(match[1]!, match[2]!.trim());
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
result.push(line);
|
||||
const match = line.match(/^\[(\d+:\d+[.:]\d+)\]/);
|
||||
if (match && transMap.has(match[1]!)) result.push(`[${match[1]}]${transMap.get(match[1]!)}`);
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
export interface Credits {
|
||||
lyricist?: string;
|
||||
composer?: string;
|
||||
arranger?: string;
|
||||
producer?: string;
|
||||
mixer?: string;
|
||||
}
|
||||
|
||||
const CREDIT_LABELS: Record<keyof Credits, RegExp> = {
|
||||
lyricist: /^(作词|作詞|词|填词|Lyricist|Lyrics? by|Written by)$/i,
|
||||
composer: /^(作曲|曲|Composer|Composed by|Music by)$/i,
|
||||
arranger: /^(编曲|編曲|Arranger|Arranged by)$/i,
|
||||
producer: /^(制作人|製作人|出品人|监制|Producer|Produced by)$/i,
|
||||
mixer: /^(混音|Mix(ing)?|Mixed by)$/i,
|
||||
};
|
||||
|
||||
/** 从歌词开头的 "作词 : xxx" 等行解析制作人员 */
|
||||
export function parseCredits(lrcText: string | undefined): Credits {
|
||||
const credits: Credits = {};
|
||||
if (!lrcText) return credits;
|
||||
for (const raw of lrcText.split('\n')) {
|
||||
const line = raw.replace(/^(\s*\[[^\]]*\]\s*)+/, '').trim();
|
||||
const match = line.match(/^([^::]{1,12})\s*[::]\s*(.+)$/);
|
||||
if (!match) continue;
|
||||
const label = match[1]!.trim();
|
||||
const value = formatPersonNames(match[2]!.trim());
|
||||
if (!value) continue;
|
||||
for (const [key, pattern] of Object.entries(CREDIT_LABELS) as Array<[keyof Credits, RegExp]>) {
|
||||
if (pattern.test(label) && !credits[key]) {
|
||||
credits[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return credits;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 基于全局 fetch 的网络工具:JSON 请求、二进制下载、带进度的流式落盘
|
||||
import fs from 'node:fs';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
export const UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36';
|
||||
|
||||
interface RequestOpts {
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function withTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
export async function fetchJson<T = any>(url: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, Accept: 'application/json', ...opts.headers },
|
||||
signal: withTimeout(opts.timeoutMs ?? 15000, opts.signal),
|
||||
});
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new Error(`JSON 解析失败 (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBuffer(url: string, opts: RequestOpts = {}): Promise<Buffer> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, ...opts.headers },
|
||||
signal: withTimeout(opts.timeoutMs ?? 30000, opts.signal),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
received: number;
|
||||
total: number; // 0 表示未知
|
||||
speed: number; // bytes/s
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式下载到文件。先写入 dest.part,完成后再改名,避免半截文件被当作已下载。
|
||||
* idleTimeoutMs 内没有收到数据则中止。
|
||||
*/
|
||||
export async function downloadToFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
opts: { signal?: AbortSignal; onProgress?: (p: Progress) => void; idleTimeoutMs?: number; headers?: Record<string, string> } = {},
|
||||
): Promise<number> {
|
||||
const idle = new AbortController();
|
||||
const signal = opts.signal ? AbortSignal.any([opts.signal, idle.signal]) : idle.signal;
|
||||
const idleMs = opts.idleTimeoutMs ?? 30000;
|
||||
let idleTimer = setTimeout(() => idle.abort(new Error('下载超时:长时间未收到数据')), idleMs);
|
||||
const resetIdle = () => {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => idle.abort(new Error('下载超时:长时间未收到数据')), idleMs);
|
||||
};
|
||||
|
||||
const tmp = `${dest}.part`;
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA, ...opts.headers }, signal });
|
||||
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const total = Number(res.headers.get('content-length')) || 0;
|
||||
let received = 0;
|
||||
let lastBytes = 0;
|
||||
let lastTime = Date.now();
|
||||
let speed = 0;
|
||||
|
||||
const meter = new Transform({
|
||||
transform(chunk: Buffer, _enc, cb) {
|
||||
received += chunk.length;
|
||||
resetIdle();
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastTime;
|
||||
if (elapsed >= 300) {
|
||||
speed = ((received - lastBytes) * 1000) / elapsed;
|
||||
lastBytes = received;
|
||||
lastTime = now;
|
||||
opts.onProgress?.({ received, total, speed });
|
||||
}
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
await pipeline(Readable.fromWeb(res.body as any), meter, fs.createWriteStream(tmp), { signal });
|
||||
opts.onProgress?.({ received, total: total || received, speed });
|
||||
await fs.promises.rename(tmp, dest);
|
||||
return received;
|
||||
} catch (e) {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
throw signal.aborted && signal.reason instanceof Error ? signal.reason : e;
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 单曲下载流水线(由旧版 lib/downloader.js 泛化而来,与平台无关):
|
||||
// 解析音源 → 元数据 → 同名文件检查 → 流式下载 → 歌词 → 专辑封面 → 写标签
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Layout } from '../config.ts';
|
||||
import { resolveTarget } from './layout.ts';
|
||||
import { mergeLrc, parseCredits } from './lyrics.ts';
|
||||
import { downloadToFile, fetchBuffer, type Progress } from './net/http.ts';
|
||||
import type { QualityKey } from './quality.ts';
|
||||
import { embedTags, type TagMeta } from './tagging.ts';
|
||||
import { FatalError, type MusicProvider, type TrackMeta } from './types.ts';
|
||||
import { errorMessage, sleep } from './util.ts';
|
||||
|
||||
export interface DownloadOptions {
|
||||
quality: QualityKey;
|
||||
layout: Layout;
|
||||
retries: number;
|
||||
lyric: boolean;
|
||||
translatedLyric: boolean;
|
||||
embedTags: boolean;
|
||||
albumCover: boolean;
|
||||
}
|
||||
|
||||
const TAG_OVERHEAD_MAX = 8 * 1024 * 1024;
|
||||
|
||||
export type Stage ='resolve' | 'metadata' | 'download' | 'lyric' | 'cover' | 'tag';
|
||||
|
||||
export interface Reporter {
|
||||
stage?(stage: Stage, message: string): void;
|
||||
progress?(p: Progress): void;
|
||||
log?(level: 'info' | 'warn', message: string): void;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
source: string;
|
||||
id: string;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
quality: QualityKey;
|
||||
bitrate?: number;
|
||||
size: number;
|
||||
file: string; // 绝对路径
|
||||
relFile: string; // 相对音乐目录
|
||||
skipped: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface Context {
|
||||
musicDir: string;
|
||||
signal?: AbortSignal;
|
||||
reporter?: Reporter;
|
||||
}
|
||||
|
||||
export async function downloadTrack(
|
||||
provider: MusicProvider,
|
||||
id: string,
|
||||
opts: DownloadOptions,
|
||||
ctx: Context,
|
||||
): Promise<DownloadResult> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= opts.retries; attempt++) {
|
||||
try {
|
||||
return await downloadOnce(provider, id, opts, ctx);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
if (e instanceof FatalError || ctx.signal?.aborted) throw e;
|
||||
if (attempt < opts.retries) {
|
||||
const waitSec = attempt * 3;
|
||||
ctx.reporter?.log?.('warn', `第 ${attempt} 次失败:${errorMessage(e)},${waitSec} 秒后重试`);
|
||||
await sleep(waitSec * 1000, ctx.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadOptions, ctx: Context): Promise<DownloadResult> {
|
||||
const { reporter, signal } = ctx;
|
||||
const warnings: string[] = [];
|
||||
const warn = (msg: string) => {
|
||||
warnings.push(msg);
|
||||
reporter?.log?.('warn', msg);
|
||||
};
|
||||
|
||||
reporter?.stage?.('resolve', '解析音源');
|
||||
const audio = await provider.resolveAudio(id, opts.quality, signal);
|
||||
|
||||
reporter?.stage?.('metadata', '获取元数据');
|
||||
const meta = await provider.getTrackMeta(id).catch(() => null);
|
||||
const name = audio.name || meta?.name || id;
|
||||
const artists = audio.artists || meta?.artists || '未知歌手';
|
||||
const album = audio.album || meta?.album || '';
|
||||
|
||||
const target = resolveTarget(ctx.musicDir, opts.layout, {
|
||||
name,
|
||||
artists,
|
||||
album,
|
||||
albumArtistPrimary: meta?.albumArtistPrimary,
|
||||
trackNo: meta?.trackNo,
|
||||
trackTotal: meta?.trackTotal,
|
||||
});
|
||||
await fs.promises.mkdir(target.dir, { recursive: true });
|
||||
const dest = path.join(target.dir, `${target.baseName}.${audio.ext}`);
|
||||
|
||||
// 同名文件视为已下载的条件:不小于音源的 99%,且最多比音源大 8MB(嵌入的封面 + 歌词会让文件变大)。
|
||||
// 下载先写 .part 再改名,不会留下半截文件。
|
||||
let skipped = false;
|
||||
const existing = await fs.promises.stat(dest).catch(() => null);
|
||||
if (existing && (!audio.size || (existing.size >= audio.size * 0.99 && existing.size <= audio.size + TAG_OVERHEAD_MAX))) {
|
||||
skipped = true;
|
||||
reporter?.log?.('info', '文件已存在,跳过下载');
|
||||
} else {
|
||||
if (existing) reporter?.log?.('info', '已有同名文件但大小不同,覆盖下载');
|
||||
reporter?.stage?.('download', '下载音频');
|
||||
await downloadToFile(audio.url, dest, { signal, onProgress: (p) => reporter?.progress?.(p) });
|
||||
}
|
||||
|
||||
let lrc: string | undefined;
|
||||
if (opts.lyric) {
|
||||
reporter?.stage?.('lyric', '保存歌词');
|
||||
const lyric = await provider.getLyric(id);
|
||||
if (lyric?.lrc) {
|
||||
lrc = lyric.lrc;
|
||||
await fs.promises.writeFile(path.join(target.dir, `${target.baseName}.lrc`), lyric.lrc, 'utf-8');
|
||||
if (opts.translatedLyric && lyric.tlyric?.trim()) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(target.dir, `${target.baseName}.合并翻译.lrc`),
|
||||
mergeLrc(lyric.lrc, lyric.tlyric),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn('未获取到歌词');
|
||||
}
|
||||
}
|
||||
|
||||
const coverUrl = meta?.cover || audio.cover;
|
||||
const wantAlbumCover = opts.albumCover && target.isAlbumDir;
|
||||
let cover: Buffer | null = null;
|
||||
if (coverUrl && (opts.embedTags || wantAlbumCover)) {
|
||||
reporter?.stage?.('cover', '获取封面');
|
||||
cover = await fetchBuffer(coverUrl, { signal }).catch((e) => {
|
||||
warn(`封面下载失败:${errorMessage(e)}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (cover && wantAlbumCover) {
|
||||
const coverPath = path.join(target.dir, 'cover.jpg');
|
||||
const stat = await fs.promises.stat(coverPath).catch(() => null);
|
||||
if (!stat?.size) await fs.promises.writeFile(coverPath, cover);
|
||||
}
|
||||
|
||||
if (opts.embedTags) {
|
||||
reporter?.stage?.('tag', '写入标签');
|
||||
try {
|
||||
const method = await embedTags(dest, cover, buildTagMeta(name, artists, album, meta, lrc));
|
||||
reporter?.log?.('info', `封面+标签+歌词已嵌入 (${method})`);
|
||||
} catch (e) {
|
||||
warn(`标签写入失败:${errorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const size = (await fs.promises.stat(dest)).size;
|
||||
return {
|
||||
source: provider.source,
|
||||
id,
|
||||
name,
|
||||
artists,
|
||||
album,
|
||||
quality: audio.quality,
|
||||
bitrate: audio.bitrate,
|
||||
size,
|
||||
file: dest,
|
||||
relFile: path.relative(ctx.musicDir, dest).split(path.sep).join('/'),
|
||||
skipped,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/** 组装标签字段,规则同旧版 downloader.js */
|
||||
export function buildTagMeta(
|
||||
name: string,
|
||||
artist: string,
|
||||
album: string,
|
||||
meta: TrackMeta | null,
|
||||
lrc: string | undefined,
|
||||
): TagMeta {
|
||||
const tag: TagMeta = { name, artist, album, lyrics: lrc, ...parseCredits(lrc) };
|
||||
if (!meta) return tag;
|
||||
if (meta.trackNo) tag.trackNo = meta.trackTotal ? `${meta.trackNo}/${meta.trackTotal}` : String(meta.trackNo);
|
||||
if (meta.disc) tag.disc = meta.disc;
|
||||
if (meta.albumArtist) tag.albumArtist = meta.albumArtist;
|
||||
if (meta.compilation) tag.compilation = true;
|
||||
if (meta.year) tag.year = meta.year;
|
||||
if (meta.date) tag.date = meta.date;
|
||||
if (meta.genre) tag.genre = meta.genre;
|
||||
if (meta.publisher) tag.publisher = meta.publisher;
|
||||
return tag;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { qqCredentials } from '../../accounts.ts';
|
||||
import { config } from '../../config.ts';
|
||||
import { UA } from '../net/http.ts';
|
||||
import type { LinkRef, MusicProvider, Source } from '../types.ts';
|
||||
import { NeteaseProvider } from './netease/index.ts';
|
||||
import { QQProvider } from './qq/index.ts';
|
||||
|
||||
export const netease = new NeteaseProvider(config.netease);
|
||||
export const qq = new QQProvider(qqCredentials);
|
||||
|
||||
const providers: Record<Source, MusicProvider> = { netease, qq };
|
||||
|
||||
export function getProvider(source: string): MusicProvider {
|
||||
const provider = providers[source as Source];
|
||||
if (!provider) throw Object.assign(new Error(`不支持的音乐来源: ${source}`), { status: 400 });
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function availableSources(): Source[] {
|
||||
return Object.keys(providers) as Source[];
|
||||
}
|
||||
|
||||
/** 识别分享链接 / ID;纯数字按网易云歌曲 ID,14 位 00 开头按 QQ songmid */
|
||||
export function parseAnyLink(input: string): LinkRef | null {
|
||||
for (const provider of Object.values(providers)) {
|
||||
const ref = provider.parseLink(input);
|
||||
if (ref) return ref;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const SHORT_HOSTS = /^(?:c\d*\.y\.qq\.com|url\.cn|163cn\.tv|y\.music\.163\.com\/m\/s)/;
|
||||
|
||||
/** 先同步识别;识别不了的短链跟随一次跳转后再识别 */
|
||||
export async function resolveLink(input: string): Promise<LinkRef | null> {
|
||||
const direct = parseAnyLink(input);
|
||||
if (direct) return direct;
|
||||
const url = input.trim().match(/https?:\/\/\S+/)?.[0];
|
||||
if (!url) return null;
|
||||
try {
|
||||
const host = new URL(url).host;
|
||||
if (!SHORT_HOSTS.test(host) && !/qq\.com|163/.test(host)) return null;
|
||||
let current = url;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const res = await fetch(current, { redirect: 'manual', headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(8000) });
|
||||
const location = res.headers.get('location');
|
||||
if (!location) break;
|
||||
current = new URL(location, current).toString();
|
||||
const ref = parseAnyLink(current);
|
||||
if (ref) return ref;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// 网易云 provider:音频地址 / 搜索 / 歌词 / 歌单走 chksz 接口,丰富元数据直接请求 music.163.com
|
||||
import { fetchJson, UA } from '../../net/http.ts';
|
||||
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 { formatPersonNames, joinNames, TtlCache } from '../../util.ts';
|
||||
|
||||
export interface NeteaseConfig {
|
||||
apikey: string;
|
||||
music: string;
|
||||
lyric: string;
|
||||
search: string;
|
||||
playlist: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
const LEVEL: Record<QualityKey, string> = {
|
||||
standard: 'standard',
|
||||
high: 'exhigh',
|
||||
lossless: 'lossless',
|
||||
hires: 'hires',
|
||||
master: 'jymaster',
|
||||
spatial: 'sky',
|
||||
effect: 'jyeffect',
|
||||
};
|
||||
const LEVEL_TO_KEY = Object.fromEntries(Object.entries(LEVEL).map(([k, v]) => [v, k])) as Record<string, QualityKey>;
|
||||
|
||||
const COMPILATION_NAMES = ['群星', 'variousartists', 'variousartist', 'va'];
|
||||
|
||||
/** 网易云歌曲对象中 l/m/h/sq/hr 字段声明的音质(chksz 的搜索结果没有这些字段) */
|
||||
function declaredQualities(t: any): QualityKey[] | undefined {
|
||||
if (!t || !('h' in t || 'sq' in t || 'hr' in t)) return undefined;
|
||||
const out: QualityKey[] = [];
|
||||
if (t.l || t.m) out.push('standard');
|
||||
if (t.h) out.push('high');
|
||||
if (t.sq) out.push('lossless');
|
||||
if (t.hr) out.push('hires');
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ChkszResponse<T> {
|
||||
code: number;
|
||||
msg?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
interface NeteaseAlbumRaw {
|
||||
id: string;
|
||||
name: string;
|
||||
publishTime?: number;
|
||||
company?: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
size: number;
|
||||
artists: Array<{ id: number; name: string }>;
|
||||
albumArtist: string;
|
||||
isCompilation: boolean;
|
||||
picUrl?: string;
|
||||
songs: any[];
|
||||
}
|
||||
|
||||
export class NeteaseProvider implements MusicProvider {
|
||||
readonly source = 'netease' as const;
|
||||
readonly qualities = ['standard', 'high', 'lossless', 'hires', 'master', 'spatial', 'effect'] as const;
|
||||
private readonly cfg: NeteaseConfig;
|
||||
private readonly albumCache = new TtlCache<NeteaseAlbumRaw | null>(10 * 60 * 1000);
|
||||
|
||||
constructor(cfg: NeteaseConfig) {
|
||||
this.cfg = cfg;
|
||||
}
|
||||
|
||||
// ---- chksz ----
|
||||
|
||||
private chksz<T>(endpoint: string, params: Record<string, string | number>, signal?: AbortSignal) {
|
||||
const url = new URL(endpoint);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
||||
if (this.cfg.apikey) url.searchParams.set('apikey', this.cfg.apikey);
|
||||
return fetchJson<ChkszResponse<T>>(url.toString(), { signal });
|
||||
}
|
||||
|
||||
async search(keyword: string, { page = 1, limit = 30 } = {}): Promise<Paged<TrackSummary>> {
|
||||
const res = await this.chksz<any>(this.cfg.search, { keyword, limit, offset: (page - 1) * limit });
|
||||
if (res.code !== 200) throw new Error(res.msg || '搜索失败');
|
||||
const songs: any[] = Array.isArray(res.data) ? res.data : res.data?.songs || [];
|
||||
return {
|
||||
items: songs.map((s) => ({
|
||||
source: this.source,
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artists: formatPersonNames(s.artists || ''),
|
||||
album: s.album || '',
|
||||
cover: s.picUrl,
|
||||
duration: s.duration,
|
||||
})),
|
||||
total: res.data?.total ?? songs.length,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async getLyric(id: string): Promise<Lyric | null> {
|
||||
try {
|
||||
const res = await this.chksz<Lyric>(this.cfg.lyric, { id });
|
||||
return res.code === 200 && res.data ? res.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylist(id: string): Promise<PlaylistDetail | null> {
|
||||
const res = await this.chksz<any>(this.cfg.playlist, { id });
|
||||
const data = res.data;
|
||||
if (!data?.tracks?.length) return null;
|
||||
return {
|
||||
source: this.source,
|
||||
id: String(data.id ?? id),
|
||||
name: data.name,
|
||||
cover: data.coverImgUrl,
|
||||
creator: data.creator?.nickname,
|
||||
trackCount: data.trackCount ?? data.tracks.length,
|
||||
tracks: data.tracks.map((t: any) => this.toSummary(t)),
|
||||
};
|
||||
}
|
||||
|
||||
async resolveAudio(id: string, quality: QualityKey, signal?: AbortSignal): Promise<ResolvedAudio> {
|
||||
let lastMsg = '';
|
||||
for (const q of fallbackChain(quality)) {
|
||||
const res = await this.chksz<any>(this.cfg.music, { id, level: LEVEL[q] }, signal);
|
||||
const d = res.data;
|
||||
if (res.code === 200 && d?.url) {
|
||||
return {
|
||||
url: d.url,
|
||||
ext: (d.url.match(/\.(mp3|flac|m4a|wav|aac)(\?|$)/i)?.[1] || 'mp3').toLowerCase(),
|
||||
quality: LEVEL_TO_KEY[d.level] ?? q,
|
||||
bitrate: d.br ? Math.round(d.br / 1000) : undefined,
|
||||
size: d.size,
|
||||
md5: d.md5,
|
||||
name: d.name,
|
||||
artists: formatPersonNames(d.artist),
|
||||
album: d.album,
|
||||
cover: d.picUrl,
|
||||
};
|
||||
}
|
||||
lastMsg = res.msg || `code ${res.code}`;
|
||||
// 404 = 该音质不可用,继续降级;其他错误直接抛出交给重试
|
||||
if (res.code !== 404) throw new Error(lastMsg);
|
||||
}
|
||||
throw new FatalError(`无可用音源:${lastMsg}`);
|
||||
}
|
||||
|
||||
// ---- music.163.com ----
|
||||
|
||||
private async web(pathname: string): Promise<any | null> {
|
||||
try {
|
||||
return await fetchJson(new URL(pathname, `${this.cfg.baseUrl}/`).toString(), {
|
||||
headers: { Referer: this.cfg.baseUrl, 'User-Agent': UA, Cookie: 'os=pc' },
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private fetchAlbumRaw(albumId: string): Promise<NeteaseAlbumRaw | null> {
|
||||
return this.albumCache.getOrSet(albumId, async () => {
|
||||
const data = await this.web(`/api/v1/album/${albumId}`);
|
||||
if (!data) return null;
|
||||
const album = data.album || data;
|
||||
if (!album?.name) return null;
|
||||
const artists = (album.artists?.length ? album.artists : [album.artist]).filter((a: any) => a?.name);
|
||||
const albumArtist = joinNames(artists);
|
||||
return {
|
||||
id: String(album.id),
|
||||
name: album.name,
|
||||
publishTime: album.publishTime || undefined,
|
||||
company: album.company || '',
|
||||
description: album.description || '',
|
||||
tags: album.tags || [],
|
||||
size: album.size || 0,
|
||||
artists,
|
||||
albumArtist,
|
||||
isCompilation: COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')),
|
||||
picUrl: album.picUrl || '',
|
||||
songs: data.songs || album.songs || [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAlbum(id: string): Promise<AlbumDetail | null> {
|
||||
const raw = await this.fetchAlbumRaw(id);
|
||||
if (!raw) return null;
|
||||
return {
|
||||
source: this.source,
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
artist: raw.albumArtist,
|
||||
artistRefs: raw.artists.map((a) => ({ id: String(a.id), name: a.name })),
|
||||
cover: raw.picUrl,
|
||||
publishTime: raw.publishTime,
|
||||
size: raw.size || raw.songs.length,
|
||||
company: raw.company,
|
||||
description: raw.description,
|
||||
tracks: raw.songs.map((s) => this.toSummary(s, raw)),
|
||||
};
|
||||
}
|
||||
|
||||
async getTrackMeta(id: string): Promise<TrackMeta | null> {
|
||||
const data = await this.web(`/api/v1/song/detail?ids=[${id}]`);
|
||||
const song = data?.songs?.[0];
|
||||
if (!song) return null;
|
||||
|
||||
const meta: TrackMeta = {
|
||||
source: this.source,
|
||||
id: String(song.id),
|
||||
name: song.name,
|
||||
artists: joinNames(song.ar),
|
||||
album: song.al?.name || '',
|
||||
albumId: song.al?.id ? String(song.al.id) : undefined,
|
||||
cover: song.al?.picUrl,
|
||||
duration: song.dt,
|
||||
trackNo: song.no || undefined,
|
||||
disc: song.cd || undefined,
|
||||
};
|
||||
|
||||
if (meta.albumId) {
|
||||
const album = await this.fetchAlbumRaw(meta.albumId);
|
||||
if (album) {
|
||||
meta.albumArtist = album.albumArtist || undefined;
|
||||
meta.albumArtistPrimary = album.artists[0]?.name;
|
||||
meta.compilation = album.isCompilation || undefined;
|
||||
meta.trackTotal = album.size || undefined;
|
||||
meta.cover = album.picUrl || meta.cover;
|
||||
if (album.publishTime) {
|
||||
const date = new Date(album.publishTime);
|
||||
meta.year = date.getFullYear();
|
||||
meta.date = date.toISOString().slice(0, 10);
|
||||
}
|
||||
if (album.tags.length) meta.genre = album.tags.join(', ');
|
||||
if (album.company) meta.publisher = album.company;
|
||||
}
|
||||
}
|
||||
if (!meta.albumArtist) meta.albumArtist = meta.artists || undefined;
|
||||
return meta;
|
||||
}
|
||||
|
||||
async getArtist(id: string): Promise<ArtistDetail | null> {
|
||||
const [detail, albums] = await Promise.all([
|
||||
this.web(`/api/v1/artist/${id}`),
|
||||
this.web(`/api/artist/albums/${id}?limit=100&offset=0`),
|
||||
]);
|
||||
const artist = detail?.artist;
|
||||
if (!artist?.name) return null;
|
||||
return {
|
||||
source: this.source,
|
||||
id: String(artist.id),
|
||||
name: artist.name,
|
||||
avatar: artist.picUrl || artist.img1v1Url || undefined,
|
||||
description: artist.briefDesc || undefined,
|
||||
tracks: (detail.hotSongs || []).map((s: any) => this.toSummary(s)),
|
||||
albums: (albums?.hotAlbums || []).map(
|
||||
(a: any): AlbumSummary => ({
|
||||
source: this.source,
|
||||
id: String(a.id),
|
||||
name: a.name,
|
||||
artist: joinNames(a.artists?.length ? a.artists : [a.artist]),
|
||||
cover: a.picUrl || undefined,
|
||||
publishTime: a.publishTime || undefined,
|
||||
size: a.size || undefined,
|
||||
type: a.subType || a.type || undefined,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
parseLink(input: string): LinkRef | null {
|
||||
const text = input.trim();
|
||||
if (/^\d+$/.test(text)) return { source: this.source, kind: 'track', id: text };
|
||||
if (!/163\.com|163cn\.tv/.test(text)) return null;
|
||||
const m = text.match(/(song|album|playlist|artist)(?:\/|\?(?:.*&)?id=)(\d+)/);
|
||||
if (!m) return null;
|
||||
const kind = m[1] === 'song' ? 'track' : (m[1] as LinkRef['kind']);
|
||||
return { source: this.source, kind, id: m[2]! };
|
||||
}
|
||||
|
||||
private toSummary(t: any, album?: NeteaseAlbumRaw): TrackSummary {
|
||||
const artists: any[] = t.ar || t.artists || [];
|
||||
return {
|
||||
source: this.source,
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
artists: joinNames(artists),
|
||||
artistRefs: artists.filter((a) => a?.id && a?.name).map((a) => ({ id: String(a.id), name: a.name })),
|
||||
album: t.al?.name || album?.name || '',
|
||||
albumId: t.al?.id ? String(t.al.id) : album?.id,
|
||||
cover: t.al?.picUrl || album?.picUrl,
|
||||
duration: t.dt || t.duration,
|
||||
trackNo: t.no || undefined,
|
||||
qualities: declaredQualities(t),
|
||||
vip: t.fee === 1 || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// QQ 音乐 musicu.fcg 网关。已登录时在 comm 中带上 uin + authst,并附带完整 Cookie。
|
||||
// (旧 qq-music-api 的 getMusicPlay 没把 Cookie 发给上游,所以拿不到 VIP 音源)
|
||||
import { UA } from '../../net/http.ts';
|
||||
|
||||
export interface QQCredentials {
|
||||
/** 当前保存的完整 Cookie 字符串,未登录返回 null */
|
||||
getCookie(): string | null;
|
||||
}
|
||||
|
||||
export function parseCookie(raw: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of raw.split(/;\s*/)) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface QQAuth {
|
||||
cookie: string;
|
||||
uin: string;
|
||||
musicKey: string;
|
||||
}
|
||||
|
||||
export function authFromCookie(raw: string | null): QQAuth | null {
|
||||
if (!raw) return null;
|
||||
const ck = parseCookie(raw);
|
||||
const uin = (ck.uin || ck.wxuin || '').replace(/^o0*/, '');
|
||||
const musicKey = ck.qm_keyst || ck.qqmusic_key || '';
|
||||
return uin && musicKey ? { cookie: raw, uin, musicKey } : null;
|
||||
}
|
||||
|
||||
export type MusicuRequest = { module: string; method: string; param: Record<string, unknown> };
|
||||
export type MusicuResult = { code: number; data?: any };
|
||||
|
||||
export class QQClient {
|
||||
private readonly creds: QQCredentials;
|
||||
|
||||
constructor(creds: QQCredentials) {
|
||||
this.creds = creds;
|
||||
}
|
||||
|
||||
get auth(): QQAuth | null {
|
||||
return authFromCookie(this.creds.getCookie());
|
||||
}
|
||||
|
||||
async musicu<K extends string>(
|
||||
reqs: Record<K, MusicuRequest>,
|
||||
opts: { signal?: AbortSignal; auth?: QQAuth | null; comm?: Record<string, unknown> } = {},
|
||||
): Promise<Record<K, MusicuResult>> {
|
||||
const auth = opts.auth === undefined ? this.auth : opts.auth;
|
||||
const body = {
|
||||
comm: {
|
||||
cv: 4747474,
|
||||
ct: 24,
|
||||
format: 'json',
|
||||
inCharset: 'utf-8',
|
||||
outCharset: 'utf-8',
|
||||
notice: 0,
|
||||
platform: 'yqq.json',
|
||||
needNewCode: 1,
|
||||
g_tk: 5381,
|
||||
g_tk_new_20200303: 5381,
|
||||
uin: auth ? Number(auth.uin) : 0,
|
||||
...(auth ? { authst: auth.musicKey } : {}),
|
||||
...opts.comm,
|
||||
},
|
||||
...reqs,
|
||||
};
|
||||
const res = await fetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://y.qq.com/',
|
||||
Origin: 'https://y.qq.com',
|
||||
'Content-Type': 'application/json',
|
||||
...(auth ? { Cookie: auth.cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000),
|
||||
});
|
||||
const json = (await res.json()) as Record<string, any>;
|
||||
if (json.code !== 0) throw new Error(`QQ 音乐接口错误 (code ${json.code})`);
|
||||
return json as Record<K, MusicuResult>;
|
||||
}
|
||||
|
||||
/** 单个请求的便捷封装:code 非 0 时抛错 */
|
||||
async call(req: MusicuRequest, signal?: AbortSignal): Promise<any> {
|
||||
const { r } = await this.musicu({ r: req }, { signal });
|
||||
if (r.code !== 0) throw new Error(`QQ 音乐接口错误 ${req.method} (code ${r.code})`);
|
||||
return r.data;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// QQ 音乐登录:QQ 扫码(ptlogin2 → graph.qq.com OAuth → QQConnectLogin)与 musickey 续期
|
||||
// 协议参考 L-1124/QQMusicApi 的 modules/login.py
|
||||
import { UA } from '../../net/http.ts';
|
||||
import { parseCookie, type QQClient } from './client.ts';
|
||||
|
||||
const APPID = '716027609';
|
||||
const DAID = '383';
|
||||
const THIRD_AID = '100497308';
|
||||
const PT_REFERER = 'https://xui.ptlogin2.qq.com/';
|
||||
|
||||
export function hash33(s: string, h = 0): number {
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
||||
return h & 0x7fffffff;
|
||||
}
|
||||
|
||||
/** 登录得到的凭据(字段名同 QQ 音乐接口返回) */
|
||||
export interface QQCredential {
|
||||
musicid: string;
|
||||
musickey: string;
|
||||
loginType: number; // 2 = QQ,1 = 微信
|
||||
openid?: string;
|
||||
unionid?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
refreshKey?: string;
|
||||
expiredAt?: number; // access_token 过期时间(秒)
|
||||
keyCreatedAt?: number; // musickey 生成时间(秒)
|
||||
keyExpiresIn?: number; // musickey 有效期(秒)
|
||||
encryptUin?: string;
|
||||
}
|
||||
|
||||
/** 从网页 Cookie 还原凭据(网页 Cookie 里没有 refresh_key) */
|
||||
export function credentialFromCookie(raw: string, refreshKey?: string): QQCredential | null {
|
||||
const ck = parseCookie(raw);
|
||||
const musicid = (ck.uin || ck.qqmusic_uin || ck.wxuin || '').replace(/^o0*/, '');
|
||||
const musickey = ck.qm_keyst || ck.qqmusic_key || '';
|
||||
if (!musicid || !musickey) return null;
|
||||
return {
|
||||
musicid,
|
||||
musickey,
|
||||
loginType: Number(ck.tmeLoginType || (ck.wxuin ? 1 : 2)),
|
||||
openid: ck.psrf_qqopenid || ck.wxopenid || undefined,
|
||||
unionid: ck.psrf_qqunionid || ck.wxunionid || undefined,
|
||||
accessToken: ck.psrf_qqaccess_token || undefined,
|
||||
refreshToken: ck.psrf_qqrefresh_token || ck.wxrefresh_token || undefined,
|
||||
refreshKey,
|
||||
expiredAt: Number(ck.psrf_access_token_expiresAt) || undefined,
|
||||
keyCreatedAt: Number(ck.psrf_musickey_createtime) || undefined,
|
||||
encryptUin: ck.euin || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把凭据写回网页 Cookie 形式,保留原 Cookie 中其他字段 */
|
||||
export function cookieFromCredential(c: QQCredential, base = ''): string {
|
||||
const ck = parseCookie(base);
|
||||
const set = (k: string, v: string | number | undefined) => {
|
||||
if (v !== undefined && v !== '') ck[k] = String(v);
|
||||
};
|
||||
set('uin', c.musicid);
|
||||
set('qqmusic_key', c.musickey);
|
||||
set('qm_keyst', c.musickey);
|
||||
set('tmeLoginType', c.loginType);
|
||||
set('login_type', c.loginType === 1 ? 2 : 1);
|
||||
set('euin', c.encryptUin);
|
||||
set('psrf_qqopenid', c.openid);
|
||||
set('psrf_qqunionid', c.unionid);
|
||||
set('psrf_qqaccess_token', c.accessToken);
|
||||
set('psrf_qqrefresh_token', c.refreshToken);
|
||||
set('psrf_access_token_expiresAt', c.expiredAt);
|
||||
set('psrf_musickey_createtime', c.keyCreatedAt);
|
||||
return Object.entries(ck)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function toCredential(data: any, loginType: number): QQCredential {
|
||||
if (!data?.musickey || !(data.musicid || data.str_musicid)) throw new Error('登录接口未返回 musickey');
|
||||
return {
|
||||
musicid: String(data.str_musicid || data.musicid),
|
||||
musickey: data.musickey,
|
||||
loginType: data.loginType || loginType,
|
||||
openid: data.openid || undefined,
|
||||
unionid: data.unionid || undefined,
|
||||
accessToken: data.access_token || undefined,
|
||||
refreshToken: data.refresh_token || undefined,
|
||||
refreshKey: data.refresh_key || undefined,
|
||||
expiredAt: data.expired_at || undefined,
|
||||
keyCreatedAt: data.musickeyCreateTime || Math.floor(Date.now() / 1000),
|
||||
keyExpiresIn: data.keyExpiresIn || undefined,
|
||||
encryptUin: data.encryptUin || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const LOGIN_ERRORS: Record<number, string> = {
|
||||
1000: '登录已过期',
|
||||
104400: '登录已过期',
|
||||
104401: '登录已过期',
|
||||
20271: '账号受限',
|
||||
20274: '设备数量超限',
|
||||
20277: '账号已被封禁',
|
||||
20278: '请求过于频繁',
|
||||
};
|
||||
|
||||
function checkLoginResult(r: { code: number; data?: any }): any {
|
||||
if (r.code !== 0) throw new Error(LOGIN_ERRORS[r.code] ?? `登录失败 (code ${r.code})`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
/** 用 musicu 换取 / 续期 musickey */
|
||||
async function musicLogin(client: QQClient, module: string, method: string, param: Record<string, unknown>, loginType: number, withAuth: boolean) {
|
||||
const { r } = await client.musicu({ r: { module, method, param } }, { auth: withAuth ? undefined : null, comm: { tmeLoginType: loginType } });
|
||||
return toCredential(checkLoginResult(r), loginType);
|
||||
}
|
||||
|
||||
/** 续期 musickey;失败抛错(调用方决定是否标记失效) */
|
||||
export function refreshCredential(client: QQClient, c: QQCredential): Promise<QQCredential> {
|
||||
const param =
|
||||
c.loginType === 1
|
||||
? { openid: c.openid, refresh_token: c.refreshToken, str_musicid: c.musicid, musickey: c.musickey, unionid: c.unionid, refresh_key: c.refreshKey, loginMode: 2 }
|
||||
: {
|
||||
openid: c.openid,
|
||||
access_token: c.accessToken,
|
||||
refresh_token: c.refreshToken,
|
||||
expired_in: c.expiredAt,
|
||||
musicid: Number(c.musicid),
|
||||
musickey: c.musickey,
|
||||
refresh_key: c.refreshKey,
|
||||
loginMode: 2,
|
||||
};
|
||||
return musicLogin(client, 'music.login.LoginServer', 'Login', param, c.loginType, true);
|
||||
}
|
||||
|
||||
// ---------- QQ 扫码 ----------
|
||||
|
||||
function setCookies(res: Response): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of res.headers.getSetCookie()) {
|
||||
const [pair] = line.split(';');
|
||||
const i = pair!.indexOf('=');
|
||||
const value = pair!.slice(i + 1);
|
||||
if (i > 0 && value) out[pair!.slice(0, i).trim()] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const cookieHeader = (jar: Record<string, string>) =>
|
||||
Object.entries(jar)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
|
||||
export interface QRCode {
|
||||
qrsig: string;
|
||||
image: string; // data:image/png;base64,...
|
||||
}
|
||||
|
||||
export async function createQR(): Promise<QRCode> {
|
||||
const url = new URL('https://ssl.ptlogin2.qq.com/ptqrshow');
|
||||
const params = { appid: APPID, e: '2', l: 'M', s: '3', d: '72', v: '4', t: String(Math.random()), daid: DAID, pt_3rd_aid: THIRD_AID };
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url, { headers: { Referer: PT_REFERER, 'User-Agent': UA }, signal: AbortSignal.timeout(10000) });
|
||||
const qrsig = setCookies(res).qrsig;
|
||||
if (!res.ok || !qrsig) throw new Error('获取二维码失败');
|
||||
const image = Buffer.from(await res.arrayBuffer()).toString('base64');
|
||||
return { qrsig, image: `data:image/png;base64,${image}` };
|
||||
}
|
||||
|
||||
export type QRState = 'waiting' | 'scanned' | 'expired' | 'refused' | 'done';
|
||||
|
||||
const QR_STATES: Record<string, QRState> = { '66': 'waiting', '67': 'scanned', '65': 'expired', '68': 'refused', '0': 'done' };
|
||||
|
||||
export async function pollQR(client: QQClient, qrsig: string): Promise<{ state: QRState; message?: string; credential?: QQCredential }> {
|
||||
const url = new URL('https://ssl.ptlogin2.qq.com/ptqrlogin');
|
||||
const params = {
|
||||
u1: 'https://graph.qq.com/oauth2.0/login_jump',
|
||||
ptqrtoken: String(hash33(qrsig)),
|
||||
ptredirect: '0',
|
||||
h: '1',
|
||||
t: '1',
|
||||
g: '1',
|
||||
from_ui: '1',
|
||||
ptlang: '2052',
|
||||
action: `0-0-${Date.now()}`,
|
||||
js_ver: '20102616',
|
||||
js_type: '1',
|
||||
pt_uistyle: '40',
|
||||
aid: APPID,
|
||||
daid: DAID,
|
||||
pt_3rd_aid: THIRD_AID,
|
||||
has_onekey: '1',
|
||||
};
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url, { headers: { Referer: PT_REFERER, 'User-Agent': UA, Cookie: `qrsig=${qrsig}` }, signal: AbortSignal.timeout(10000) });
|
||||
const text = await res.text();
|
||||
const body = text.match(/ptuiCB\((.*?)\)/)?.[1];
|
||||
const args = body ? [...body.matchAll(/'((?:\\.|[^'])*)'/g)].map((m) => m[1]!) : [];
|
||||
const state = QR_STATES[args[0] ?? ''];
|
||||
if (!state) throw new Error(`二维码状态解析失败: ${text.slice(0, 120)}`);
|
||||
if (state !== 'done') return { state, message: args[4] };
|
||||
|
||||
const redirect = args[2] ?? '';
|
||||
const sigx = redirect.match(/[?&]ptsigx=(.+?)&s_url/)?.[1];
|
||||
const uin = redirect.match(/[?&]uin=(.+?)&service/)?.[1];
|
||||
if (!sigx || !uin) throw new Error('扫码成功但未拿到登录参数');
|
||||
return { state, credential: await authorizeQQ(client, uin, sigx) };
|
||||
}
|
||||
|
||||
async function authorizeQQ(client: QQClient, uin: string, sigx: string): Promise<QQCredential> {
|
||||
const sig = new URL('https://ssl.ptlogin2.graph.qq.com/check_sig');
|
||||
const sigParams = {
|
||||
uin,
|
||||
pttype: '1',
|
||||
service: 'ptqrlogin',
|
||||
nodirect: '0',
|
||||
ptsigx: sigx,
|
||||
s_url: 'https://graph.qq.com/oauth2.0/login_jump',
|
||||
ptlang: '2052',
|
||||
ptredirect: '100',
|
||||
aid: APPID,
|
||||
daid: DAID,
|
||||
j_later: '0',
|
||||
low_login_hour: '0',
|
||||
regmaster: '0',
|
||||
pt_login_type: '3',
|
||||
pt_aid: '0',
|
||||
pt_aaid: '16',
|
||||
pt_light: '0',
|
||||
pt_3rd_aid: THIRD_AID,
|
||||
};
|
||||
for (const [k, v] of Object.entries(sigParams)) sig.searchParams.set(k, v);
|
||||
const sigRes = await fetch(sig, { headers: { Referer: PT_REFERER, 'User-Agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(10000) });
|
||||
const jar = setCookies(sigRes);
|
||||
if (!jar.p_skey) throw new Error('获取 p_skey 失败');
|
||||
|
||||
const form = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: THIRD_AID,
|
||||
redirect_uri: 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/',
|
||||
scope: 'get_user_info,get_app_friends',
|
||||
state: 'state',
|
||||
switch: '',
|
||||
from_ptlogin: '1',
|
||||
src: '1',
|
||||
update_auth: '1',
|
||||
openapi: '1010_1030',
|
||||
g_tk: String(hash33(jar.p_skey, 5381)),
|
||||
auth_time: String(Date.now()),
|
||||
ui: crypto.randomUUID(),
|
||||
});
|
||||
const authRes = await fetch('https://graph.qq.com/oauth2.0/authorize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Cookie: cookieHeader(jar) },
|
||||
body: form,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const code = (authRes.headers.get('location') || '').match(/[?&]code=(.+?)(?:&|$)/)?.[1];
|
||||
if (!code) throw new Error('OAuth 授权失败,未拿到 code');
|
||||
|
||||
return musicLogin(client, 'QQConnectLogin.LoginServer', 'QQLogin', { code }, 2, false);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 统一音质档位。各平台在自己的 provider 里把它映射到平台参数。
|
||||
export const QUALITY_KEYS = ['standard', 'high', 'lossless', 'hires', 'master', 'spatial', 'effect'] as const;
|
||||
export type QualityKey = (typeof QUALITY_KEYS)[number];
|
||||
|
||||
export const QUALITY_INFO: Record<QualityKey, { label: string; badge: string }> = {
|
||||
standard: { label: '标准', badge: '128K' },
|
||||
high: { label: '极高', badge: '320K' },
|
||||
lossless: { label: '无损', badge: 'FLAC' },
|
||||
hires: { label: 'Hi-Res', badge: 'HI-RES' },
|
||||
master: { label: '超清母带', badge: 'MASTER' },
|
||||
spatial: { label: '空间音频', badge: 'SPATIAL' },
|
||||
effect: { label: '高清臻品', badge: 'EFFECT' },
|
||||
};
|
||||
|
||||
export function isQualityKey(value: unknown): value is QualityKey {
|
||||
return typeof value === 'string' && (QUALITY_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// 请求的音质不可用时,按此顺序向下降级
|
||||
const LADDER: QualityKey[] = ['master', 'hires', 'lossless', 'high', 'standard'];
|
||||
|
||||
export function fallbackChain(requested: QualityKey): QualityKey[] {
|
||||
if (requested === 'spatial' || requested === 'effect') return [requested, ...LADDER];
|
||||
return LADDER.slice(LADDER.indexOf(requested));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 音频标签写入:MP3 优先 node-id3,其余格式(FLAC 等)或 node-id3 失败时用 ffmpeg。
|
||||
// 字段与旧版 163-music-dl/lib/tagging.js 保持一致。
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import NodeID3, { type Tags } from 'node-id3';
|
||||
import { formatPersonNames } from './util.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface TagMeta {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumArtist?: string;
|
||||
compilation?: boolean;
|
||||
year?: number;
|
||||
date?: string;
|
||||
trackNo?: string; // "3" 或 "3/12"
|
||||
disc?: string;
|
||||
genre?: string;
|
||||
publisher?: string;
|
||||
composer?: string;
|
||||
lyricist?: string;
|
||||
arranger?: string;
|
||||
producer?: string;
|
||||
mixer?: string;
|
||||
lyrics?: string;
|
||||
}
|
||||
|
||||
let ffmpegAvailable: boolean | null = null;
|
||||
|
||||
export async function hasFFmpeg(): Promise<boolean> {
|
||||
if (ffmpegAvailable === null) {
|
||||
try {
|
||||
await execFileAsync('ffmpeg', ['-version'], { windowsHide: true });
|
||||
ffmpegAvailable = true;
|
||||
} catch {
|
||||
ffmpegAvailable = false;
|
||||
}
|
||||
}
|
||||
return ffmpegAvailable;
|
||||
}
|
||||
|
||||
async function writeId3(audioPath: string, cover: Buffer | null, meta: TagMeta): Promise<void> {
|
||||
const tags: Tags & Record<string, unknown> = {
|
||||
title: meta.name,
|
||||
artist: formatPersonNames(meta.artist),
|
||||
album: meta.album,
|
||||
};
|
||||
if (cover) {
|
||||
tags.image = { mime: 'image/jpeg', type: { id: 3 }, description: 'Cover (front)', imageBuffer: cover };
|
||||
}
|
||||
if (meta.albumArtist) tags.performerInfo = formatPersonNames(meta.albumArtist);
|
||||
if (meta.compilation) tags.TCMP = '1';
|
||||
if (meta.year) tags.year = String(meta.year);
|
||||
if (meta.date) tags.date = String(meta.date);
|
||||
if (meta.trackNo) tags.trackNumber = String(meta.trackNo);
|
||||
if (meta.disc) tags.partOfSet = String(meta.disc);
|
||||
if (meta.genre) tags.genre = meta.genre;
|
||||
if (meta.composer) tags.composer = formatPersonNames(meta.composer);
|
||||
if (meta.lyricist) tags.textWriter = formatPersonNames(meta.lyricist);
|
||||
if (meta.publisher) tags.publisher = meta.publisher;
|
||||
|
||||
const userTexts: Array<{ description: string; value: string }> = [];
|
||||
if (meta.arranger) userTexts.push({ description: 'ARRANGER', value: formatPersonNames(meta.arranger) });
|
||||
if (meta.producer) userTexts.push({ description: 'PRODUCER', value: formatPersonNames(meta.producer) });
|
||||
if (meta.mixer) userTexts.push({ description: 'MIXER', value: formatPersonNames(meta.mixer) });
|
||||
if (userTexts.length) tags.userDefinedText = userTexts;
|
||||
if (meta.lyrics) tags.unsynchronisedLyrics = { language: 'chi', text: meta.lyrics };
|
||||
|
||||
// 写文件模式下成功时 resolve(undefined),失败则 reject
|
||||
await NodeID3.Promise.write(tags, audioPath);
|
||||
}
|
||||
|
||||
async function writeWithFFmpeg(audioPath: string, cover: Buffer | null, meta: TagMeta): Promise<void> {
|
||||
const ext = path.extname(audioPath).toLowerCase();
|
||||
const coverPath = `${audioPath}.cover.jpg`;
|
||||
const tmpPath = `${audioPath}.tmp${ext}`;
|
||||
try {
|
||||
const args = ['-y', '-i', audioPath];
|
||||
if (cover) {
|
||||
await fs.promises.writeFile(coverPath, cover);
|
||||
args.push('-i', coverPath, '-map', '0:a', '-map', '1:0', '-c', 'copy', '-disposition:v:0', 'attached_pic');
|
||||
} else {
|
||||
args.push('-map', '0', '-c', 'copy');
|
||||
}
|
||||
const addMeta = (key: string, value: unknown) => {
|
||||
if (value !== undefined && value !== null && value !== '') args.push('-metadata', `${key}=${value}`);
|
||||
};
|
||||
addMeta('title', meta.name);
|
||||
addMeta('artist', formatPersonNames(meta.artist));
|
||||
addMeta('album', meta.album);
|
||||
addMeta('album_artist', formatPersonNames(meta.albumArtist || meta.artist));
|
||||
addMeta('compilation', meta.compilation ? '1' : undefined);
|
||||
addMeta('date', meta.date || meta.year);
|
||||
addMeta('track', meta.trackNo);
|
||||
addMeta('disc', meta.disc);
|
||||
addMeta('genre', meta.genre);
|
||||
addMeta('composer', formatPersonNames(meta.composer));
|
||||
addMeta('publisher', meta.publisher);
|
||||
addMeta('lyricist', formatPersonNames(meta.lyricist));
|
||||
addMeta('arranger', formatPersonNames(meta.arranger));
|
||||
addMeta('producer', formatPersonNames(meta.producer));
|
||||
addMeta('mixer', formatPersonNames(meta.mixer));
|
||||
if (meta.lyrics && meta.lyrics.length <= 8000) addMeta('lyrics', meta.lyrics);
|
||||
if (cover) args.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)');
|
||||
if (ext === '.mp3') args.push('-id3v2_version', '3');
|
||||
args.push(tmpPath);
|
||||
|
||||
await execFileAsync('ffmpeg', args, { windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
||||
await fs.promises.rename(tmpPath, audioPath);
|
||||
} finally {
|
||||
await fs.promises.rm(coverPath, { force: true });
|
||||
await fs.promises.rm(tmpPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入封面、标签与内嵌歌词,返回所用的写入方式 */
|
||||
export async function embedTags(audioPath: string, cover: Buffer | null, meta: TagMeta): Promise<'node-id3' | 'ffmpeg'> {
|
||||
const ext = path.extname(audioPath).toLowerCase();
|
||||
if (ext === '.mp3') {
|
||||
try {
|
||||
await writeId3(audioPath, cover, meta);
|
||||
return 'node-id3';
|
||||
} catch {
|
||||
// 落到 ffmpeg
|
||||
}
|
||||
}
|
||||
if (!(await hasFFmpeg())) throw new Error('写入标签需要 ffmpeg(非 MP3 格式)');
|
||||
await writeWithFFmpeg(audioPath, cover, meta);
|
||||
return 'ffmpeg';
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { QualityKey } from './quality.ts';
|
||||
|
||||
export type Source = 'netease' | 'qq';
|
||||
|
||||
export interface ArtistRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 搜索结果 / 列表里的一首歌 */
|
||||
export interface TrackSummary {
|
||||
source: Source;
|
||||
id: string;
|
||||
name: string;
|
||||
artists: string; // 已格式化为 "A / B"
|
||||
artistRefs?: ArtistRef[];
|
||||
album: string;
|
||||
albumId?: string;
|
||||
cover?: string;
|
||||
duration?: number; // ms
|
||||
trackNo?: number;
|
||||
/** 平台声明可下载的音质(已知时) */
|
||||
qualities?: QualityKey[];
|
||||
vip?: boolean;
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** 用于写标签与决定目录结构的完整元数据 */
|
||||
export interface TrackMeta {
|
||||
source: Source;
|
||||
id: string;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
albumId?: string;
|
||||
cover?: string;
|
||||
duration?: number;
|
||||
trackNo?: number;
|
||||
trackTotal?: number;
|
||||
disc?: string;
|
||||
albumArtist?: string; // 全部专辑艺人,"A / B"
|
||||
albumArtistPrimary?: string; // 第一位专辑艺人,用作目录名
|
||||
compilation?: boolean;
|
||||
year?: number;
|
||||
date?: string; // YYYY-MM-DD
|
||||
genre?: string;
|
||||
publisher?: string;
|
||||
}
|
||||
|
||||
export interface AlbumSummary {
|
||||
source: Source;
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
cover?: string;
|
||||
publishTime?: number;
|
||||
size?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface AlbumDetail extends AlbumSummary {
|
||||
artistRefs?: ArtistRef[];
|
||||
company?: string;
|
||||
description?: string;
|
||||
tracks: TrackSummary[];
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
source: Source;
|
||||
id: string;
|
||||
name: string;
|
||||
cover?: string;
|
||||
creator?: string;
|
||||
description?: string;
|
||||
trackCount: number;
|
||||
tracks: TrackSummary[];
|
||||
}
|
||||
|
||||
export interface ArtistDetail {
|
||||
source: Source;
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
tracks: TrackSummary[];
|
||||
albums: AlbumSummary[];
|
||||
}
|
||||
|
||||
export interface Lyric {
|
||||
lrc?: string;
|
||||
tlyric?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedAudio {
|
||||
url: string;
|
||||
ext: string; // 不含点,例如 flac
|
||||
quality: QualityKey; // 实际拿到的音质
|
||||
bitrate?: number; // kbps
|
||||
size?: number; // bytes
|
||||
md5?: string;
|
||||
/** 解析接口顺带返回的基础信息,作为元数据兜底 */
|
||||
name?: string;
|
||||
artists?: string;
|
||||
album?: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
export type LinkKind = 'track' | 'album' | 'playlist' | 'artist';
|
||||
|
||||
export interface LinkRef {
|
||||
source: Source;
|
||||
kind: LinkKind;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly source: Source;
|
||||
readonly qualities: readonly QualityKey[];
|
||||
search(keyword: string, opts?: { page?: number; limit?: number }): Promise<Paged<TrackSummary>>;
|
||||
getTrackMeta(id: string): Promise<TrackMeta | null>;
|
||||
getAlbum(id: string): Promise<AlbumDetail | null>;
|
||||
getPlaylist(id: string): Promise<PlaylistDetail | null>;
|
||||
getArtist(id: string): Promise<ArtistDetail | null>;
|
||||
getLyric(id: string): Promise<Lyric | null>;
|
||||
/** 按降级链解析音频地址;全部失败时抛错 */
|
||||
resolveAudio(id: string, quality: QualityKey, signal?: AbortSignal): Promise<ResolvedAudio>;
|
||||
/** 同步识别链接;短链等需要请求的情况返回 null,交给 expandLink */
|
||||
parseLink(input: string): LinkRef | null;
|
||||
}
|
||||
|
||||
/** 无法通过重试解决的错误(例如版权缺失),流水线遇到它不再重试 */
|
||||
export class FatalError extends Error {
|
||||
override name = 'FatalError';
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export function sanitize(name: string): string {
|
||||
return name.replace(/[/\\:*?"<>|]/g, '_').trim();
|
||||
}
|
||||
|
||||
export function sanitizePathSegment(name: unknown, fallback: string): string {
|
||||
const value = sanitize(String(name || '')).replace(/[. ]+$/g, '');
|
||||
return value && value !== '.' && value !== '..' ? value : fallback;
|
||||
}
|
||||
|
||||
// "A/B" "A /B" -> "A / B"
|
||||
export function formatPersonNames(value: string): string;
|
||||
export function formatPersonNames(value: string | undefined): string | undefined;
|
||||
export function formatPersonNames(value: string | undefined): string | undefined {
|
||||
return typeof value === 'string' ? value.replace(/\s*\/\s*/g, ' / ') : value;
|
||||
}
|
||||
|
||||
export function joinNames(names: Array<{ name?: string } | string | undefined> | undefined): string {
|
||||
return (names || [])
|
||||
.map((item) => (typeof item === 'string' ? item : item?.name))
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(signal.reason);
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal!.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// 简单的带过期时间的内存缓存,用于专辑详情等批量下载时反复请求的数据
|
||||
export class TtlCache<V> {
|
||||
private map = new Map<string, { value: V; expires: number }>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly max: number;
|
||||
|
||||
constructor(ttlMs: number, max = 500) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
async getOrSet(key: string, load: () => Promise<V>): Promise<V> {
|
||||
const hit = this.map.get(key);
|
||||
if (hit && hit.expires > Date.now()) return hit.value;
|
||||
const value = await load();
|
||||
if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value!);
|
||||
this.map.set(key, { value, expires: Date.now() + this.ttlMs });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user