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
+33
View File
@@ -0,0 +1,33 @@
// 冒烟测试:用流水线下载一首歌并打印过程事件
// npm run smoke -- <歌曲ID或链接> [音质] [album|flat]
// MUSIC_DIR=/tmp/x npm run smoke -- 287398 lossless
import { importQQCookieFile } from '../src/accounts.ts';
import { config } from '../src/config.ts';
import { downloadTrack } from '../src/core/pipeline.ts';
import { getProvider, resolveLink } from '../src/core/providers/index.ts';
import { isQualityKey } from '../src/core/quality.ts';
importQQCookieFile();
const [input, quality = config.defaults.quality, layout = config.defaults.layout] = process.argv.slice(2);
const ref = input ? await resolveLink(input) : null;
if (!ref || ref.kind !== 'track' || !isQualityKey(quality)) {
console.error('用法: npm run smoke -- <歌曲ID或链接> [standard|high|lossless|hires|master|spatial|effect] [album|flat]');
process.exit(1);
}
const mb = (n: number) => (n / 1048576).toFixed(1);
const result = await downloadTrack(
getProvider(ref.source),
ref.id,
{ ...config.defaults, quality, layout: layout === 'flat' ? 'flat' : 'album' },
{
musicDir: config.musicDir,
reporter: {
stage: (stage, msg) => console.log(`▸ [${stage}] ${msg}`),
progress: (p) => process.stdout.write(`\r ${mb(p.received)}/${mb(p.total)} MB ${(p.speed / 1024).toFixed(0)} KB/s `),
log: (level, msg) => console.log(`${process.stdout.isTTY ? '\n' : ''} ${level === 'warn' ? '⚠' : '·'} ${msg}`),
},
},
);
console.log('\n✔', JSON.stringify(result, null, 2));
+280
View File
@@ -0,0 +1,280 @@
// 平台账号(QQ 音乐)。凭据加密存储,永不返回给前端。
// 支持:粘贴 Cookie / Cookie 文件导入 / QQ 扫码登录,以及后台自动续期 musickey。
import crypto from 'node:crypto';
import fs from 'node:fs';
import { config } from './config.ts';
import { authFromCookie, parseCookie, type QQCredentials } from './core/providers/qq/client.ts';
import type { QQProvider } from './core/providers/qq/index.ts';
import {
cookieFromCredential,
createQR,
credentialFromCookie,
pollQR,
refreshCredential,
type QQCredential,
type QRState,
} from './core/providers/qq/login.ts';
import { errorMessage } from './core/util.ts';
import { getSetting, setSetting } from './db.ts';
import { bus } from './events.ts';
import { decrypt, encrypt } from './secret.ts';
interface Secret {
cookie: string;
refreshKey?: string;
keyExpiresIn?: number; // 秒
}
interface StoredQQ {
cookie: string; // 加密后的 Secret JSON(旧数据为加密后的 Cookie 字符串)
savedAt: string;
via?: 'cookie' | 'file' | 'qr' | 'refresh';
fileMtime?: number;
}
interface RefreshRecord {
at: string;
ok: boolean;
error?: string;
}
let secret: Secret | null = load();
function load(): Secret | null {
try {
const stored = getSetting<StoredQQ | null>('qq.account', null);
if (!stored) return null;
const plain = decrypt(stored.cookie);
return plain.startsWith('{') ? (JSON.parse(plain) as Secret) : { cookie: plain };
} catch {
return null;
}
}
export const qqCredentials: QQCredentials = { getCookie: () => secret?.cookie ?? null };
function persist(next: Secret, via: StoredQQ['via'], fileMtime?: number) {
const prev = getSetting<StoredQQ | null>('qq.account', null);
secret = next;
setSetting('qq.account', {
cookie: encrypt(JSON.stringify(next)),
savedAt: new Date().toISOString(),
via,
// 续期 / 扫码不应让旧的 cookie.txt 在下次启动时覆盖新凭据
fileMtime: fileMtime ?? prev?.fileMtime,
} satisfies StoredQQ);
statusCache = null;
bus.emit('account', 'qq');
}
function normalizeCookie(raw: string): string {
return raw
.replace(/^cookie:\s*/i, '')
.split(/;\s*|\r?\n/)
.map((s) => s.trim())
.filter((s) => s.includes('='))
.join('; ');
}
export function saveQQCookie(raw: string, via: 'cookie' | 'file' = 'cookie', fileMtime?: number): void {
const cookie = normalizeCookie(raw);
if (!authFromCookie(cookie)) {
throw Object.assign(new Error('Cookie 中缺少 uin 或 qm_keyst / qqmusic_key'), { status: 400 });
}
persist({ cookie }, via, fileMtime);
setSetting('qq.refresh', null);
}
function saveCredential(c: QQCredential, via: 'qr' | 'refresh') {
// 扫码登录换了账号时不保留旧 Cookie 的其他字段
const base = via === 'refresh' ? (secret?.cookie ?? '') : '';
persist({ cookie: cookieFromCredential(c, base), refreshKey: c.refreshKey ?? secret?.refreshKey, keyExpiresIn: c.keyExpiresIn }, via);
}
export function clearQQCookie(): void {
secret = null;
setSetting('qq.account', null);
setSetting('qq.refresh', null);
statusCache = null;
bus.emit('account', 'qq');
}
/** config.qqCookieFile 指向的文件比上次导入新时自动导入(本地开发 / NAS 上放一个 cookie.txt 即可) */
export function importQQCookieFile(): boolean {
const file = config.qqCookieFile;
if (!file || !fs.existsSync(file)) return false;
const mtime = fs.statSync(file).mtimeMs;
const stored = getSetting<StoredQQ | null>('qq.account', null);
if (stored?.fileMtime && stored.fileMtime >= mtime) return false;
try {
saveQQCookie(fs.readFileSync(file, 'utf-8'), 'file', mtime);
return true;
} catch (e) {
console.warn(`⚠️ QQ Cookie 文件导入失败: ${errorMessage(e)}`);
return false;
}
}
// ---------- 续期 ----------
let refreshing: Promise<boolean> | null = null;
/** 续期 musickey。成功后新凭据立即持久化 */
export function refreshQQ(provider: QQProvider): Promise<boolean> {
refreshing ??= (async () => {
const cred = secret ? credentialFromCookie(secret.cookie, secret.refreshKey) : null;
if (!cred) return false;
try {
if (!cred.refreshToken) throw new Error('凭据中没有 refresh_token,无法自动续期(请扫码登录)');
const next = await refreshCredential(provider.client, cred);
saveCredential(next, 'refresh');
setSetting('qq.refresh', { at: new Date().toISOString(), ok: true } satisfies RefreshRecord);
console.log('🔑 QQ 音乐 musickey 已续期');
return true;
} catch (e) {
setSetting('qq.refresh', { at: new Date().toISOString(), ok: false, error: errorMessage(e) } satisfies RefreshRecord);
statusCache = null;
bus.emit('account', 'qq');
console.warn(`⚠️ QQ 音乐续期失败: ${errorMessage(e)}`);
return false;
} finally {
refreshing = null;
}
})();
return refreshing;
}
const DEFAULT_KEY_LIFETIME = 24 * 3600; // 未知有效期时按 1 天估算,提前续期
function keyAge(): { age: number; lifetime: number } | null {
if (!secret) return null;
const created = Number(parseCookie(secret.cookie).psrf_musickey_createtime);
if (!created) return null;
return { age: Date.now() / 1000 - created, lifetime: secret.keyExpiresIn || DEFAULT_KEY_LIFETIME };
}
/** 后台维护:启动后与每 3 小时检查一次,凭据过半寿命或已失效时续期 */
export function startQQKeeper(provider: QQProvider) {
const tick = async () => {
if (!secret) return;
const k = keyAge();
const stale = k ? k.age > k.lifetime * 0.5 : false;
const status = await getQQStatus(provider, true);
if (stale || status.valid === false) await refreshQQ(provider);
};
setTimeout(() => void tick(), 60 * 1000).unref();
setInterval(() => void tick(), 3 * 3600 * 1000).unref();
}
// ---------- 扫码登录 ----------
interface QRSession {
qrsig: string;
createdAt: number;
state: QRState | 'error';
message?: string;
}
const qrSessions = new Map<string, QRSession>();
export async function startQRLogin(): Promise<{ id: string; image: string }> {
for (const [id, s] of qrSessions) if (Date.now() - s.createdAt > 5 * 60 * 1000) qrSessions.delete(id);
const qr = await createQR();
const id = crypto.randomUUID();
qrSessions.set(id, { qrsig: qr.qrsig, createdAt: Date.now(), state: 'waiting' });
return { id, image: qr.image };
}
export async function pollQRLogin(provider: QQProvider, id: string): Promise<{ state: QRSession['state']; message?: string; qq?: QQStatus }> {
const session = qrSessions.get(id);
if (!session) return { state: 'expired', message: '二维码已失效' };
if (session.state === 'done' || session.state === 'expired' || session.state === 'refused' || session.state === 'error') {
return { state: session.state, message: session.message };
}
try {
const result = await pollQR(provider.client, session.qrsig);
session.state = result.state;
session.message = result.message;
if (result.credential) {
saveCredential(result.credential, 'qr');
setSetting('qq.refresh', null);
qrSessions.delete(id);
return { state: 'done', qq: await getQQStatus(provider, true) };
}
} catch (e) {
session.state = 'error';
session.message = errorMessage(e);
}
return { state: session.state, message: session.message };
}
// ---------- 状态 ----------
export interface QQStatus {
loggedIn: boolean;
valid?: boolean;
uin?: string; // 打码
nick?: string;
avatar?: string;
vip?: { active: boolean; label: string; expires?: string };
keyCreatedAt?: string;
keyExpiresAt?: string;
savedAt?: string;
via?: StoredQQ['via'];
canRefresh?: boolean;
lastRefresh?: RefreshRecord | null;
error?: string;
}
let statusCache: { at: number; value: QQStatus } | null = null;
function maskUin(uin: string): string {
return uin.length > 4 ? `${uin.slice(0, 2)}${'*'.repeat(uin.length - 4)}${uin.slice(-2)}` : uin;
}
export async function getQQStatus(provider: QQProvider, force = false): Promise<QQStatus> {
if (!force && statusCache && Date.now() - statusCache.at < 5 * 60 * 1000) return statusCache.value;
const auth = authFromCookie(secret?.cookie ?? null);
if (!auth || !secret) return { loggedIn: false };
const ck = parseCookie(auth.cookie);
const stored = getSetting<StoredQQ | null>('qq.account', null);
const created = Number(ck.psrf_musickey_createtime);
const base: QQStatus = {
loggedIn: true,
uin: maskUin(auth.uin),
keyCreatedAt: created ? new Date(created * 1000).toISOString() : undefined,
keyExpiresAt: created && secret.keyExpiresIn ? new Date((created + secret.keyExpiresIn) * 1000).toISOString() : undefined,
savedAt: stored?.savedAt,
via: stored?.via,
canRefresh: !!ck.psrf_qqrefresh_token,
lastRefresh: getSetting<RefreshRecord | null>('qq.refresh', null),
};
try {
const res = await provider.client.musicu({
vip: { module: 'VipLogin.VipLoginInter', method: 'vip_login_base', param: {} },
user: { module: 'userInfo.BaseUserInfoServer', method: 'get_user_baseinfo_v2', param: { vec_uin: [auth.uin] } },
login: { module: 'music.UserInfo.userInfoServer', method: 'GetLoginUserInfo', param: {} },
});
const id = res.vip.data?.identity || {};
const user = res.user.data?.map_userinfo?.[auth.uin];
// GetLoginUserInfo 在 musickey 失效时返回 1000 / 104400 等
const valid = res.login.code === 0;
const svip = Number(id.HugeVipFlag || id.SuperVipFlag || 0) > 0;
const value: QQStatus = {
...base,
valid,
nick: user?.nick,
avatar: user?.headurl?.replace(/^http:/, 'https:'),
vip: {
active: Number(id.vip || 0) > 0 || svip,
label: svip ? '豪华绿钻' : Number(id.vip || 0) > 0 ? '绿钻' : '普通用户',
expires: id.eightEnd || id.HugeVipEnd || id.VipEnd || undefined,
},
};
statusCache = { at: Date.now(), value };
return value;
} catch (e) {
return { ...base, valid: false, error: errorMessage(e) };
}
}
+43
View File
@@ -0,0 +1,43 @@
import fs from 'node:fs';
import path from 'node:path';
import { bodyParser } from '@koa/bodyparser';
import Koa from 'koa';
import serve from 'koa-static';
import { ROOT_DIR } from './config.ts';
import { errorMessage } from './core/util.ts';
import { authGuard } from './auth.ts';
import { api } from './routes/api.ts';
const WEB_DIR = path.join(ROOT_DIR, 'dist/web');
export function createApp(): Koa {
const app = new Koa();
app.use(async (ctx, next) => {
try {
await next();
} catch (e) {
const status = (e as { status?: number }).status ?? 500;
ctx.status = status;
ctx.body = { error: errorMessage(e) };
if (status >= 500) console.error(`✖ ${ctx.method} ${ctx.path}:`, e);
}
});
app.use(authGuard);
app.use(bodyParser());
app.use(api.routes()).use(api.allowedMethods());
// 前端静态资源 + SPA 回退
if (fs.existsSync(WEB_DIR)) {
app.use(serve(WEB_DIR, { maxage: 7 * 24 * 3600 * 1000, index: false }));
app.use(async (ctx) => {
if (ctx.method !== 'GET' || ctx.path.startsWith('/api')) return;
ctx.type = 'html';
ctx.set('Cache-Control', 'no-cache');
ctx.body = fs.createReadStream(path.join(WEB_DIR, 'index.html'));
});
}
return app;
}
+59
View File
@@ -0,0 +1,59 @@
// 可选的访问密码(单用户)。未设置密码时不做任何拦截,方便本机使用。
import type { Context, Next } from 'koa';
import { getSetting, setSetting } from './db.ts';
import { hashPassword, sign, verifyPassword } from './secret.ts';
const COOKIE = 'sx_session';
const SESSION_DAYS = 30;
const PUBLIC_PATHS = ['/api/auth/', '/api/health'];
export function passwordEnabled(): boolean {
return !!getSetting<string | null>('auth.password', null);
}
function issue(ctx: Context) {
const expires = Date.now() + SESSION_DAYS * 86400000;
const value = `${expires}.${sign(String(expires))}`;
ctx.cookies.set(COOKIE, value, { httpOnly: true, sameSite: 'lax', expires: new Date(expires), overwrite: true });
}
export function isAuthenticated(ctx: Context): boolean {
if (!passwordEnabled()) return true;
const [expires, sig] = (ctx.cookies.get(COOKIE) || '').split('.');
return !!expires && !!sig && Number(expires) > Date.now() && sign(expires) === sig;
}
export async function authGuard(ctx: Context, next: Next) {
if (ctx.path.startsWith('/api/') && !PUBLIC_PATHS.some((p) => ctx.path.startsWith(p)) && !isAuthenticated(ctx)) {
ctx.status = 401;
ctx.body = { error: '需要登录' };
return;
}
await next();
}
export function login(ctx: Context, password: string): boolean {
const stored = getSetting<string | null>('auth.password', null);
if (!stored || !verifyPassword(password, stored)) return false;
issue(ctx);
return true;
}
export function logout(ctx: Context) {
ctx.cookies.set(COOKIE, '', { expires: new Date(0) });
}
/** 设置或清除访问密码;已有密码时需要提供当前密码 */
export function changePassword(ctx: Context, current: string | undefined, next: string | undefined) {
const stored = getSetting<string | null>('auth.password', null);
if (stored && !verifyPassword(current || '', stored)) {
throw Object.assign(new Error('当前密码不正确'), { status: 403 });
}
if (next) {
if (next.length < 4) throw Object.assign(new Error('密码至少 4 位'), { status: 400 });
setSetting('auth.password', hashPassword(next));
issue(ctx);
} else {
setSetting('auth.password', null);
}
}
+127
View File
@@ -0,0 +1,127 @@
// 应用配置:读取根目录 config.json,再用环境变量覆盖(Docker 部署时主要靠环境变量)
import fs from 'node:fs';
import path from 'node:path';
import { isQualityKey, type QualityKey } from './core/quality.ts';
export const ROOT_DIR = path.resolve(import.meta.dirname, '../..');
export type Layout = 'album' | 'flat';
export interface DownloadDefaults {
quality: QualityKey;
layout: Layout;
retries: number;
lyric: boolean;
translatedLyric: boolean;
embedTags: boolean;
albumCover: boolean;
}
export interface AppConfig {
port: number;
musicDir: string;
dataDir: string;
legacyHistoryFile: string | null;
/** QQ 音乐 Cookie 文件;比已保存的新时自动导入 */
qqCookieFile: string | null;
netease: {
apikey: string;
music: string;
lyric: string;
search: string;
playlist: string;
baseUrl: string;
};
defaults: DownloadDefaults;
}
const DEFAULTS: AppConfig = {
port: 8066,
musicDir: 'music',
dataDir: 'data',
legacyHistoryFile: null,
qqCookieFile: null,
netease: {
apikey: 'chksz_RGXIby7J6ok-GPa6nMrFgHgQtwEGD-hILAhZXpIa72Q',
music: 'https://api.chksz.com/api/163_music',
lyric: 'https://api.chksz.com/api/163_lyric',
search: 'https://api.chksz.com/api/163_search',
playlist: 'https://api.chksz.com/api/163_playlist',
baseUrl: 'https://music.163.com',
},
defaults: {
quality: 'master',
layout: 'album',
retries: 3,
lyric: true,
translatedLyric: true,
embedTags: true,
albumCover: true,
},
};
function readJson(file: string): Record<string, any> {
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`⚠️ 配置文件读取失败,使用默认配置: ${(e as Error).message}`);
}
return {};
}
}
function str(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function bool(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0') return false;
return fallback;
}
function resolvePath(value: string): string {
return path.isAbsolute(value) ? value : path.join(ROOT_DIR, value);
}
function loadConfig(): AppConfig {
const file = readJson(path.join(ROOT_DIR, 'config.json'));
const env = process.env;
const ne = { ...DEFAULTS.netease, ...(file.netease || {}) };
const df = { ...DEFAULTS.defaults, ...(file.defaults || {}) };
const quality = str(env.DEFAULT_QUALITY, df.quality);
const layout = str(env.DEFAULT_LAYOUT, df.layout);
const retries = Number(env.DEFAULT_RETRIES ?? df.retries);
return {
port: Number(env.PORT || file.server?.port || DEFAULTS.port),
musicDir: resolvePath(str(env.MUSIC_DIR, str(file.paths?.musicDir, DEFAULTS.musicDir))),
dataDir: resolvePath(str(env.DATA_DIR, str(file.paths?.dataDir, DEFAULTS.dataDir))),
legacyHistoryFile: env.LEGACY_HISTORY_FILE ? resolvePath(env.LEGACY_HISTORY_FILE) : null,
qqCookieFile: env.QQ_COOKIE_FILE || file.qq?.cookieFile ? resolvePath(str(env.QQ_COOKIE_FILE, file.qq?.cookieFile)) : null,
netease: {
apikey: str(env.CHKSZ_API_KEY, str(ne.apikey, DEFAULTS.netease.apikey)),
music: str(ne.music, DEFAULTS.netease.music),
lyric: str(ne.lyric, DEFAULTS.netease.lyric),
search: str(ne.search, DEFAULTS.netease.search),
playlist: str(ne.playlist, DEFAULTS.netease.playlist),
baseUrl: str(ne.baseUrl, DEFAULTS.netease.baseUrl).replace(/\/$/, ''),
},
defaults: {
quality: isQualityKey(quality) ? quality : DEFAULTS.defaults.quality,
layout: layout === 'flat' ? 'flat' : 'album',
retries: Number.isInteger(retries) && retries > 0 ? retries : DEFAULTS.defaults.retries,
lyric: bool(df.lyric, true),
translatedLyric: bool(df.translatedLyric, true),
embedTags: bool(df.embedTags, true),
albumCover: bool(df.albumCover, true),
},
};
}
export const config = loadConfig();
fs.mkdirSync(config.musicDir, { recursive: true });
fs.mkdirSync(config.dataDir, { recursive: true });
+37
View File
@@ -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 };
}
+58
View File
@@ -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;
}
+103
View File
@@ -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);
}
}
+201
View File
@@ -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;
}
+54
View File
@@ -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;
}
+314
View File
@@ -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,
};
}
}
+93
View File
@@ -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;
}
}
+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;
}
}
+260
View File
@@ -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);
}
+25
View File
@@ -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));
}
+134
View File
@@ -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';
}
+140
View File
@@ -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';
}
+62
View File
@@ -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;
}
}
+209
View File
@@ -0,0 +1,209 @@
// SQLite 持久化(Node 内置 node:sqlite,无需原生编译依赖)
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { config } from './config.ts';
export const db = new DatabaseSync(path.join(config.dataDir, 'shengxia.db'));
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
track_id TEXT NOT NULL,
name TEXT NOT NULL,
artists TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
quality TEXT,
bitrate INTEGER,
size INTEGER,
file TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_history_track ON history(source, track_id);
CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
track_id TEXT NOT NULL,
name TEXT NOT NULL,
artists TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
cover TEXT,
quality TEXT NOT NULL,
group_title TEXT,
status TEXT NOT NULL,
stage TEXT,
error TEXT,
actual_quality TEXT,
bitrate INTEGER,
size INTEGER,
file TEXT,
skipped INTEGER NOT NULL DEFAULT 0,
warnings TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, id);
`);
export interface HistoryEntry {
id: number;
source: string;
trackId: string;
name: string;
artists: string;
album: string;
quality: string | null;
bitrate: number | null;
size: number | null;
file: string | null;
createdAt: string;
}
const insertHistory = db.prepare(`
INSERT INTO history (source, track_id, name, artists, album, quality, bitrate, size, file, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
export function addHistory(e: Omit<HistoryEntry, 'id' | 'createdAt'> & { createdAt?: string }): void {
insertHistory.run(
e.source,
e.trackId,
e.name,
e.artists,
e.album,
e.quality,
e.bitrate,
e.size,
e.file,
e.createdAt ?? new Date().toISOString(),
);
}
const HISTORY_COLUMNS =
'id, source, track_id AS trackId, name, artists, album, quality, bitrate, size, file, created_at AS createdAt';
function historyWhere(q?: string, source?: string): { sql: string; args: string[] } {
const parts: string[] = [];
const args: string[] = [];
if (q) {
parts.push('(name LIKE ? OR artists LIKE ? OR album LIKE ?)');
args.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (source) {
parts.push('source = ?');
args.push(source);
}
return { sql: parts.length ? `WHERE ${parts.join(' AND ')}` : '', args };
}
export function listHistory(
limit = 50,
offset = 0,
filter: { q?: string; source?: string } = {},
): { items: HistoryEntry[]; total: number } {
const where = historyWhere(filter.q, filter.source);
const rows = db
.prepare(`SELECT ${HISTORY_COLUMNS} FROM history ${where.sql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`)
.all(...where.args, limit, offset) as unknown as HistoryEntry[];
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM history ${where.sql}`).get(...where.args) as { total: number };
return { items: rows, total };
}
export function allHistory(): HistoryEntry[] {
return db.prepare(`SELECT ${HISTORY_COLUMNS} FROM history ORDER BY created_at DESC, id DESC`).all() as unknown as HistoryEntry[];
}
export function historyStats() {
const totals = db.prepare('SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS size FROM history').get() as {
count: number;
size: number;
};
const bySource = db.prepare('SELECT source AS key, COUNT(*) AS count FROM history GROUP BY source ORDER BY count DESC').all();
const byQuality = db
.prepare("SELECT COALESCE(quality, 'unknown') AS key, COUNT(*) AS count FROM history GROUP BY quality ORDER BY count DESC")
.all();
const topArtists = db
.prepare("SELECT artists AS key, COUNT(*) AS count FROM history WHERE artists <> '' GROUP BY artists ORDER BY count DESC LIMIT 8")
.all();
const daily = db
.prepare(
`SELECT substr(created_at, 1, 10) AS key, COUNT(*) AS count FROM history
WHERE created_at >= ? GROUP BY key ORDER BY key`,
)
.all(new Date(Date.now() - 29 * 86400000).toISOString().slice(0, 10));
return { ...totals, bySource, byQuality, topArtists, daily };
}
export function downloadedIds(source: string, ids: string[]): Set<string> {
if (!ids.length) return new Set();
const rows = db
.prepare(`SELECT DISTINCT track_id AS id FROM history WHERE source = ? AND track_id IN (${ids.map(() => '?').join(',')})`)
.all(source, ...ids) as Array<{ id: string }>;
return new Set(rows.map((r) => r.id));
}
export function getSetting<T>(key: string, fallback: T): T {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row ? (JSON.parse(row.value) as T) : fallback;
}
export function setSetting(key: string, value: unknown): void {
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(
key,
JSON.stringify(value),
);
}
// 旧版 CLI 的 download_history.json(网易云 level 命名)只导入一次
const LEGACY_LEVEL: Record<string, string> = {
standard: 'standard',
exhigh: 'high',
lossless: 'lossless',
hires: 'hires',
jymaster: 'master',
sky: 'spatial',
jyeffect: 'effect',
};
export function importLegacyHistory(file: string | null): number {
if (!file || getSetting('legacyHistoryImported', false)) return 0;
let entries: any[];
try {
entries = JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch (e) {
console.warn(`⚠️ 旧版历史读取失败 (${file}): ${(e as Error).message}`);
return 0;
}
// 旧文件是新→旧,倒序插入保持时间顺序
db.exec('BEGIN');
try {
for (const e of [...entries].reverse()) {
if (!e?.id || !e?.name) continue;
addHistory({
source: 'netease',
trackId: String(e.id),
name: e.name,
artists: e.artist || '',
album: e.album || '',
quality: LEGACY_LEVEL[e.level] ?? e.level ?? null,
bitrate: e.bitrate ? Math.round(e.bitrate > 10000 ? e.bitrate / 1000 : e.bitrate) : null,
size: e.size ?? null,
file: e.file ?? null,
createdAt: e.timestamp,
});
}
setSetting('legacyHistoryImported', true);
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
return entries.length;
}
+5
View File
@@ -0,0 +1,5 @@
// 进程内事件总线:队列 / 设置 / 账号变化通过它推送给 SSE 客户端
import { EventEmitter } from 'node:events';
export const bus = new EventEmitter();
bus.setMaxListeners(100);
+264
View File
@@ -0,0 +1,264 @@
// 本地曲库工具:浏览目录、生成 M3U、打包 ZIP、移入回收站、按文件补全标签
import fs from 'node:fs';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import yazl from 'yazl';
import { config } from './config.ts';
import { buildTagMeta } from './core/pipeline.ts';
import { mergeLrc } from './core/lyrics.ts';
import { fetchBuffer } from './core/net/http.ts';
import { getProvider } from './core/providers/index.ts';
import { embedTags } from './core/tagging.ts';
import type { Source, TrackSummary } from './core/types.ts';
import { errorMessage, sleep } from './core/util.ts';
import { bus } from './events.ts';
export const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.ape', '.opus']);
const TRASH_DIR = path.join(config.dataDir, '.trash');
function httpError(status: number, message: string): Error {
return Object.assign(new Error(message), { status });
}
/** 把前端传来的相对路径解析到音乐目录内,禁止越界 */
export function resolveInLibrary(rel: string | undefined): string {
const target = path.resolve(config.musicDir, (rel || '').replace(/^[/\\]+/, ''));
const root = path.resolve(config.musicDir);
if (target !== root && !target.startsWith(root + path.sep)) throw httpError(400, '路径不在音乐目录内');
return target;
}
function toRel(abs: string): string {
return path.relative(config.musicDir, abs).split(path.sep).join('/');
}
export interface LibraryEntry {
name: string;
path: string;
type: 'dir' | 'audio' | 'other';
size: number;
mtime: number;
hasLyric?: boolean;
/** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */
cover?: string;
}
const DIR_IMAGES = ['cover.jpg', 'folder.jpg', 'cover.png', 'artist.jpg'];
export async function listDir(rel: string | undefined) {
const dir = resolveInLibrary(rel);
const stat = await fs.promises.stat(dir).catch(() => null);
if (!stat?.isDirectory()) throw httpError(404, '目录不存在');
const names = await fs.promises.readdir(dir);
const nameSet = new Set(names);
const entries: LibraryEntry[] = [];
for (const name of names) {
if (name.startsWith('.')) continue;
const abs = path.join(dir, name);
const s = await fs.promises.stat(abs).catch(() => null);
if (!s) continue;
if (s.isDirectory()) {
const image = DIR_IMAGES.find((img) => fs.existsSync(path.join(abs, img)));
entries.push({ name, path: toRel(abs), type: 'dir', size: 0, mtime: s.mtimeMs, cover: image ? toRel(path.join(abs, image)) : undefined });
} else {
const ext = path.extname(name).toLowerCase();
const isAudio = AUDIO_EXTS.has(ext);
const base = name.slice(0, -ext.length);
entries.push({
name,
path: toRel(abs),
type: isAudio ? 'audio' : 'other',
size: s.size,
mtime: s.mtimeMs,
hasLyric: isAudio ? nameSet.has(`${base}.lrc`) : undefined,
});
}
}
entries.sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name, 'zh-CN', { numeric: true }));
return {
path: toRel(dir),
hasCover: nameSet.has('cover.jpg'),
entries,
};
}
async function collectAudio(abs: string): Promise<string[]> {
const stat = await fs.promises.stat(abs);
if (stat.isFile()) return AUDIO_EXTS.has(path.extname(abs).toLowerCase()) ? [abs] : [];
const out: string[] = [];
for (const entry of await fs.promises.readdir(abs, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
out.push(...(await collectAudio(path.join(abs, entry.name))));
}
return out.sort((a, b) => a.localeCompare(b, 'zh-CN', { numeric: true }));
}
/** 在目录中写入 {目录名}.m3u8,路径相对该目录 */
export async function writeM3u(rel: string): Promise<string> {
const dir = resolveInLibrary(rel);
const files = await collectAudio(dir);
if (!files.length) throw httpError(400, '目录中没有音频文件');
const name = `${path.basename(dir) || 'library'}.m3u8`;
const lines = ['#EXTM3U', ...files.map((f) => path.relative(dir, f).split(path.sep).join('/'))];
await fs.promises.writeFile(path.join(dir, name), lines.join('\n') + '\n', 'utf-8');
return toRel(path.join(dir, name));
}
/** 流式打包(不压缩,音频本身已压缩) */
export async function zipStream(rel: string): Promise<{ stream: PassThrough; filename: string }> {
const target = resolveInLibrary(rel);
const stat = await fs.promises.stat(target).catch(() => null);
if (!stat) throw httpError(404, '路径不存在');
const zip = new yazl.ZipFile();
const base = stat.isDirectory() ? target : path.dirname(target);
const add = async (abs: string) => {
const s = await fs.promises.stat(abs);
if (s.isDirectory()) {
for (const name of await fs.promises.readdir(abs)) if (!name.startsWith('.')) await add(path.join(abs, name));
} else {
zip.addFile(abs, path.join(path.basename(base), path.relative(base, abs)).split(path.sep).join('/'), { compress: false });
}
};
await add(target);
zip.end();
const out = new PassThrough();
zip.outputStream.pipe(out);
return { stream: out, filename: `${path.basename(target) || 'music'}.zip` };
}
/** 移入数据目录下的回收站(保留相对路径),不永久删除 */
export async function moveToTrash(rel: string): Promise<string> {
const target = resolveInLibrary(rel);
if (target === path.resolve(config.musicDir)) throw httpError(400, '不能删除音乐根目录');
const dest = path.join(TRASH_DIR, new Date().toISOString().replace(/[:.]/g, '-'), path.relative(config.musicDir, target));
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
try {
await fs.promises.rename(target, dest);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EXDEV') throw e;
await fs.promises.cp(target, dest, { recursive: true });
await fs.promises.rm(target, { recursive: true, force: true });
}
return toRel(target);
}
export async function trashInfo() {
let size = 0;
let count = 0;
const walk = async (dir: string) => {
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => [])) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(abs);
else {
count++;
size += (await fs.promises.stat(abs)).size;
}
}
};
await walk(TRASH_DIR);
return { count, size };
}
export async function emptyTrash() {
await fs.promises.rm(TRASH_DIR, { recursive: true, force: true });
}
// ---------- 补全标签 ----------
export interface RetagJob {
id: number;
path: string;
source: Source;
total: number;
done: number;
current?: string;
results: Array<{ file: string; ok: boolean; match?: string; error?: string }>;
finished: boolean;
}
let job: RetagJob | null = null;
let jobSeq = 0;
export function currentRetagJob(): RetagJob | null {
return job;
}
/** 从路径推断 歌名 / 歌手 / 专辑:支持 "歌手/专辑/NN. 歌名" 和 "歌手 - 歌名" */
export function guessFromPath(abs: string): { title: string; artist?: string; album?: string } {
const base = path.basename(abs, path.extname(abs));
const dash = base.match(/^(.+?)\s*[-—]\s*(.+)$/);
if (dash && !/^\d+\.\s/.test(base)) return { artist: dash[1]!.trim(), title: dash[2]!.trim() };
const title = base.replace(/^\d+[.\s-]+\s*/, '').trim();
const albumDir = path.dirname(abs);
const artistDir = path.dirname(albumDir);
const root = path.resolve(config.musicDir);
if (path.resolve(artistDir) !== root && path.resolve(albumDir) !== root && path.resolve(artistDir).startsWith(root)) {
return { title, album: path.basename(albumDir), artist: path.basename(artistDir) };
}
return { title };
}
function pickBest(results: TrackSummary[], guess: ReturnType<typeof guessFromPath>): TrackSummary | undefined {
const norm = (s: string | undefined) => (s || '').toLowerCase().replace(/\s+/g, '');
const title = norm(guess.title);
const artist = norm(guess.artist);
const album = norm(guess.album);
const score = (t: TrackSummary) =>
(norm(t.name) === title ? 4 : norm(t.name).includes(title) ? 2 : 0) +
(artist && norm(t.artists).includes(artist) ? 3 : 0) +
(album && norm(t.album) === album ? 2 : 0);
return [...results].sort((a, b) => score(b) - score(a))[0];
}
async function retagFile(abs: string, source: Source): Promise<{ match: string }> {
const provider = getProvider(source);
const guess = guessFromPath(abs);
const keyword = [guess.artist, guess.title].filter(Boolean).join(' ');
const { items } = await provider.search(keyword, { limit: 8 });
const best = pickBest(items, guess);
if (!best) throw new Error('搜索无结果');
const [meta, lyric] = await Promise.all([provider.getTrackMeta(best.id).catch(() => null), provider.getLyric(best.id)]);
const dir = path.dirname(abs);
const base = path.basename(abs, path.extname(abs));
if (lyric?.lrc) {
await fs.promises.writeFile(path.join(dir, `${base}.lrc`), lyric.lrc, 'utf-8');
if (lyric.tlyric?.trim()) await fs.promises.writeFile(path.join(dir, `${base}.合并翻译.lrc`), mergeLrc(lyric.lrc, lyric.tlyric), 'utf-8');
}
const coverUrl = meta?.cover || best.cover;
const cover = coverUrl ? await fetchBuffer(coverUrl).catch(() => null) : null;
await embedTags(abs, cover, buildTagMeta(meta?.name || best.name, meta?.artists || best.artists, meta?.album || best.album, meta, lyric?.lrc));
return { match: `${best.artists} - ${best.name}${best.album ? ` 《${best.album}》` : ''}` };
}
export async function startRetag(rel: string, source: Source, onlyMissingLyric: boolean): Promise<RetagJob> {
if (job && !job.finished) throw httpError(409, '已有补全任务在进行');
const abs = resolveInLibrary(rel);
let files = await collectAudio(abs);
if (onlyMissingLyric) {
files = files.filter((f) => !fs.existsSync(path.join(path.dirname(f), `${path.basename(f, path.extname(f))}.lrc`)));
}
const current: RetagJob = { id: ++jobSeq, path: toRel(abs), source, total: files.length, done: 0, results: [], finished: false };
job = current;
bus.emit('retag', current);
void (async () => {
for (const file of files) {
current.current = toRel(file);
bus.emit('retag', current);
try {
const { match } = await retagFile(file, source);
current.results.push({ file: toRel(file), ok: true, match });
} catch (e) {
current.results.push({ file: toRel(file), ok: false, error: errorMessage(e) });
}
current.done++;
bus.emit('retag', current);
await sleep(400);
}
current.finished = true;
current.current = undefined;
bus.emit('retag', current);
})();
return current;
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from './app.ts';
import { config } from './config.ts';
import { hasFFmpeg } from './core/tagging.ts';
import { importQQCookieFile, startQQKeeper } from './accounts.ts';
import { qq } from './core/providers/index.ts';
import { importLegacyHistory } from './db.ts';
import { queue } from './queue.ts';
const imported = importLegacyHistory(config.legacyHistoryFile);
if (imported) console.log(`📥 已导入旧版下载历史 ${imported} 条`);
if (importQQCookieFile()) console.log(`🔑 已从 ${config.qqCookieFile} 导入 QQ 音乐 Cookie`);
createApp().listen(config.port, process.env.HOST || '0.0.0.0', async () => {
console.log(`🎧 声匣已启动 http://localhost:${config.port}`);
console.log(` 音乐目录: ${config.musicDir}`);
console.log(` 数据目录: ${config.dataDir}`);
queue.pump();
startQQKeeper(qq);
console.log(` ffmpeg : ${(await hasFFmpeg()) ? '✓' : '✗(FLAC 标签写入不可用)'}`);
});
+314
View File
@@ -0,0 +1,314 @@
// 下载队列:任务持久化在 SQLite,进度只在内存中并通过事件总线推送
import { config } from './config.ts';
import { downloadTrack, type Stage } from './core/pipeline.ts';
import { getProvider } from './core/providers/index.ts';
import { isQualityKey, type QualityKey } from './core/quality.ts';
import { FatalError, type Source } from './core/types.ts';
import { errorMessage, sleep } from './core/util.ts';
import { addHistory, db, getSetting, setSetting } from './db.ts';
import { bus } from './events.ts';
import { getSettings } from './settings.ts';
export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'failed' | 'canceled';
export interface Task {
id: number;
source: Source;
trackId: string;
name: string;
artists: string;
album: string;
cover: string | null;
quality: QualityKey;
groupTitle: string | null;
status: TaskStatus;
stage: Stage | null;
error: string | null;
actualQuality: string | null;
bitrate: number | null;
size: number | null;
file: string | null;
skipped: boolean;
warnings: string[];
createdAt: string;
updatedAt: string;
}
export interface TaskProgress {
id: number;
stage?: Stage;
received: number;
total: number;
speed: number;
}
export interface EnqueueItem {
source: Source;
id: string;
name?: string;
artists?: string;
album?: string;
cover?: string;
}
const COLUMNS = `id, source, track_id AS trackId, name, artists, album, cover, quality, group_title AS groupTitle,
status, stage, error, actual_quality AS actualQuality, bitrate, size, file, skipped, warnings,
created_at AS createdAt, updated_at AS updatedAt`;
function toTask(row: any): Task {
return { ...row, skipped: !!row.skipped, warnings: row.warnings ? JSON.parse(row.warnings) : [] };
}
function getTask(id: number): Task | null {
const row = db.prepare(`SELECT ${COLUMNS} FROM tasks WHERE id = ?`).get(id);
return row ? toTask(row) : null;
}
function update(id: number, fields: Record<string, unknown>): Task | null {
const keys = Object.keys(fields);
db.prepare(`UPDATE tasks SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`).run(
...(keys.map((k) => fields[k]) as any[]),
new Date().toISOString(),
id,
);
const task = getTask(id);
if (task) bus.emit('task', task);
return task;
}
type AbortReason = 'pause' | 'cancel';
class DownloadQueue {
private running = new Map<number, AbortController>();
private progress = new Map<number, TaskProgress>();
private lastStart = 0;
paused = getSetting('queue.paused', false);
constructor() {
// 上次进程退出时还在跑的任务重新排队
db.prepare("UPDATE tasks SET status = 'queued', stage = NULL WHERE status = 'running'").run();
}
list(scope: 'active' | 'finished' | 'all' = 'all', limit = 500): Task[] {
const where =
scope === 'active'
? "WHERE status IN ('queued','running','paused')"
: scope === 'finished'
? "WHERE status IN ('done','failed','canceled')"
: '';
const order = scope === 'finished' ? 'ORDER BY updated_at DESC, id DESC' : 'ORDER BY id';
return (db.prepare(`SELECT ${COLUMNS} FROM tasks ${where} ${order} LIMIT ?`).all(limit) as any[]).map(toTask);
}
progressSnapshot(): TaskProgress[] {
return [...this.progress.values()];
}
summary() {
const rows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all() as Array<{ status: TaskStatus; n: number }>;
const counts = Object.fromEntries(rows.map((r) => [r.status, r.n])) as Partial<Record<TaskStatus, number>>;
return { paused: this.paused, concurrency: getSettings().concurrency, counts };
}
private emitSummary() {
bus.emit('queue', this.summary());
}
enqueue(items: EnqueueItem[], opts: { quality?: string; group?: string } = {}): { added: Task[]; duplicates: number } {
const quality = isQualityKey(opts.quality) ? opts.quality : getSettings().quality;
const active = db.prepare("SELECT id FROM tasks WHERE source = ? AND track_id = ? AND status IN ('queued','running','paused')");
const insert = db.prepare(`
INSERT INTO tasks (source, track_id, name, artists, album, cover, quality, group_title, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)
`);
const added: Task[] = [];
let duplicates = 0;
const now = new Date().toISOString();
db.exec('BEGIN');
try {
for (const item of items) {
getProvider(item.source);
if (active.get(item.source, String(item.id))) {
duplicates++;
continue;
}
const { lastInsertRowid } = insert.run(
item.source,
String(item.id),
item.name || String(item.id),
item.artists || '',
item.album || '',
item.cover || null,
quality,
opts.group || null,
now,
now,
);
added.push(getTask(Number(lastInsertRowid))!);
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
for (const task of added) bus.emit('task', task);
this.emitSummary();
this.pump();
return { added, duplicates };
}
pause(id: number) {
const task = getTask(id);
if (task?.status === 'queued') update(id, { status: 'paused' });
else if (task?.status === 'running') this.running.get(id)?.abort('pause' satisfies AbortReason);
this.emitSummary();
}
resume(id: number) {
const task = getTask(id);
if (task?.status === 'paused') update(id, { status: 'queued', stage: null });
this.emitSummary();
this.pump();
}
retry(id: number) {
const task = getTask(id);
if (task && (task.status === 'failed' || task.status === 'canceled')) {
update(id, { status: 'queued', stage: null, error: null });
}
this.emitSummary();
this.pump();
}
retryFailed(): number {
const ids = (db.prepare("SELECT id FROM tasks WHERE status = 'failed'").all() as Array<{ id: number }>).map((r) => r.id);
for (const id of ids) update(id, { status: 'queued', stage: null, error: null });
this.emitSummary();
this.pump();
return ids.length;
}
cancel(id: number) {
const task = getTask(id);
if (task?.status === 'queued' || task?.status === 'paused') update(id, { status: 'canceled', stage: null });
else if (task?.status === 'running') this.running.get(id)?.abort('cancel' satisfies AbortReason);
this.emitSummary();
}
remove(id: number) {
if (this.running.has(id)) return;
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
bus.emit('task-removed', { ids: [id] });
this.emitSummary();
}
clearFinished(includeFailed = false) {
const statuses = includeFailed ? "('done','canceled','failed')" : "('done','canceled')";
const ids = (db.prepare(`SELECT id FROM tasks WHERE status IN ${statuses}`).all() as Array<{ id: number }>).map((r) => r.id);
db.prepare(`DELETE FROM tasks WHERE status IN ${statuses}`).run();
bus.emit('task-removed', { ids });
this.emitSummary();
return ids.length;
}
setPaused(paused: boolean) {
this.paused = paused;
setSetting('queue.paused', paused);
this.emitSummary();
if (!paused) this.pump();
}
/** 按并发数启动排队中的任务。全局暂停只阻止新任务开始,进行中的任务会跑完 */
pump() {
if (this.paused) return;
const { concurrency } = getSettings();
while (this.running.size < concurrency) {
const exclude = [...this.running.keys()];
const row = db
.prepare(
`SELECT id FROM tasks WHERE status = 'queued' ${exclude.length ? `AND id NOT IN (${exclude.join(',')})` : ''} ORDER BY id LIMIT 1`,
)
.get() as { id: number } | undefined;
if (!row) break;
const controller = new AbortController();
this.running.set(row.id, controller);
update(row.id, { status: 'running', stage: null, error: null });
void this.run(row.id, controller);
}
}
private async run(id: number, controller: AbortController) {
const task = getTask(id)!;
const settings = getSettings();
const live: TaskProgress = { id, received: 0, total: 0, speed: 0 };
this.progress.set(id, live);
try {
// 任务之间保持间隔,避免短时间内大量请求上游接口
const wait = this.lastStart + settings.gapMs - Date.now();
this.lastStart = Math.max(Date.now(), this.lastStart + settings.gapMs);
if (wait > 0) await sleep(wait, controller.signal);
const result = await downloadTrack(
getProvider(task.source),
task.trackId,
{ ...settings, quality: task.quality },
{
musicDir: config.musicDir,
signal: controller.signal,
reporter: {
stage: (stage) => {
live.stage = stage;
update(id, { stage });
},
progress: (p) => {
Object.assign(live, p);
bus.emit('progress', live);
},
},
},
);
update(id, {
status: 'done',
stage: null,
name: result.name,
artists: result.artists,
album: result.album,
actual_quality: result.quality,
bitrate: result.bitrate ?? null,
size: result.size,
file: result.relFile,
skipped: result.skipped ? 1 : 0,
warnings: JSON.stringify(result.warnings),
});
addHistory({
source: task.source,
trackId: task.trackId,
name: result.name,
artists: result.artists,
album: result.album,
quality: result.quality,
bitrate: result.bitrate ?? null,
size: result.size,
file: result.relFile,
});
bus.emit('history');
} catch (e) {
const reason = controller.signal.aborted ? (controller.signal.reason as AbortReason) : null;
if (reason === 'pause') update(id, { status: 'paused', stage: null });
else if (reason === 'cancel') update(id, { status: 'canceled', stage: null });
else {
update(id, { status: 'failed', stage: null, error: errorMessage(e) });
if (!(e instanceof FatalError)) console.warn(`✖ 任务 ${id} 失败: ${errorMessage(e)}`);
}
} finally {
this.running.delete(id);
this.progress.delete(id);
this.emitSummary();
this.pump();
}
}
}
export const queue = new DownloadQueue();
bus.on('settings', () => queue.pump());
+356
View File
@@ -0,0 +1,356 @@
import fs from 'node:fs';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import Router from '@koa/router';
import type { Context } from 'koa';
import { clearQQCookie, getQQStatus, pollQRLogin, refreshQQ, saveQQCookie, startQRLogin } from '../accounts.ts';
import { changePassword, isAuthenticated, login, logout, passwordEnabled } from '../auth.ts';
import { config } from '../config.ts';
import { availableSources, getProvider, qq, resolveLink } from '../core/providers/index.ts';
import { QUALITY_INFO, QUALITY_KEYS } from '../core/quality.ts';
import { hasFFmpeg } from '../core/tagging.ts';
import type { Source, TrackSummary } from '../core/types.ts';
import { allHistory, downloadedIds, historyStats, listHistory } from '../db.ts';
import { bus } from '../events.ts';
import {
AUDIO_EXTS,
currentRetagJob,
emptyTrash,
listDir,
moveToTrash,
resolveInLibrary,
startRetag,
trashInfo,
writeM3u,
zipStream,
} from '../library.ts';
import { queue, type EnqueueItem } from '../queue.ts';
import { getSettings, updateSettings } from '../settings.ts';
import { proxyStream } from '../stream.ts';
export const api = new Router({ prefix: '/api' });
function intParam(value: unknown, fallback: number, max = Infinity): number {
const n = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
}
function notFound(what: string): never {
throw Object.assign(new Error(`${what}不存在或获取失败`), { status: 404 });
}
function body(ctx: Context): Record<string, any> {
return (ctx.request as any).body || {};
}
/** 给列表中的歌曲标记是否已下载过 */
function markDownloaded<T extends { tracks: TrackSummary[] }>(source: string, detail: T) {
const owned = downloadedIds(source, detail.tracks.map((t) => t.id));
return { ...detail, tracks: detail.tracks.map((t) => ({ ...t, downloaded: owned.has(t.id) })) };
}
// ---------- 基础 ----------
api.get('/health', async (ctx) => {
ctx.body = { ok: true, ffmpeg: await hasFFmpeg(), sources: availableSources() };
});
api.get('/meta', (ctx) => {
ctx.body = {
sources: availableSources(),
qualities: QUALITY_KEYS.map((key) => ({ key, ...QUALITY_INFO[key] })),
sourceQualities: { netease: getProvider('netease').qualities, qq: qq.qualities },
musicDir: config.musicDir,
};
});
// ---------- 访问密码 ----------
api.get('/auth/status', (ctx) => {
ctx.body = { required: passwordEnabled(), authenticated: isAuthenticated(ctx) };
});
api.post('/auth/login', (ctx) => {
if (!login(ctx, String(body(ctx).password ?? ''))) ctx.throw(401, '密码不正确');
ctx.body = { ok: true };
});
api.post('/auth/logout', (ctx) => {
logout(ctx);
ctx.body = { ok: true };
});
api.put('/auth/password', (ctx) => {
const { current, password } = body(ctx);
changePassword(ctx, current, password);
ctx.body = { required: passwordEnabled() };
});
// ---------- 设置与账号 ----------
api.get('/settings', (ctx) => {
ctx.body = getSettings();
});
api.put('/settings', (ctx) => {
ctx.body = updateSettings(body(ctx));
});
api.get('/accounts', async (ctx) => {
ctx.body = { qq: await getQQStatus(qq, ctx.query.refresh === '1') };
});
api.post('/accounts/qq/cookie', async (ctx) => {
const cookie = String(body(ctx).cookie ?? '').trim();
if (!cookie) ctx.throw(400, '请粘贴 Cookie');
saveQQCookie(cookie);
ctx.body = { qq: await getQQStatus(qq, true) };
});
api.post('/accounts/qq/qr', async (ctx) => {
ctx.body = await startQRLogin();
});
api.get('/accounts/qq/qr/:id', async (ctx) => {
ctx.body = await pollQRLogin(qq, ctx.params.id!);
});
api.post('/accounts/qq/refresh', async (ctx) => {
const ok = await refreshQQ(qq);
ctx.body = { ok, qq: await getQQStatus(qq, true) };
});
api.delete('/accounts/qq', (ctx) => {
clearQQCookie();
ctx.body = { qq: { loggedIn: false } };
});
// ---------- 搜索与详情 ----------
api.get('/search', async (ctx) => {
const q = String(ctx.query.q ?? '').trim();
if (!q) ctx.throw(400, '请输入关键词');
const provider = getProvider(String(ctx.query.source ?? 'netease'));
const result = await provider.search(q, { page: intParam(ctx.query.page, 1), limit: intParam(ctx.query.limit, 30, 100) });
ctx.body = { ...result, items: markDownloaded(provider.source, { tracks: result.items }).tracks };
});
api.get('/resolve-link', async (ctx) => {
const ref = await resolveLink(String(ctx.query.url ?? ''));
if (!ref) ctx.throw(400, '无法识别的链接或 ID');
ctx.body = ref;
});
api.get('/:source/track/:id', async (ctx) => {
const provider = getProvider(ctx.params.source!);
const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲');
const owned = downloadedIds(provider.source, [meta.id]);
ctx.body = { ...meta, downloaded: owned.has(meta.id) };
});
api.get('/:source/album/:id', async (ctx) => {
const detail = (await getProvider(ctx.params.source!).getAlbum(ctx.params.id!)) ?? notFound('专辑');
ctx.body = markDownloaded(ctx.params.source!, detail);
});
api.get('/:source/playlist/:id', async (ctx) => {
const detail = (await getProvider(ctx.params.source!).getPlaylist(ctx.params.id!)) ?? notFound('歌单');
ctx.body = markDownloaded(ctx.params.source!, detail);
});
api.get('/:source/artist/:id', async (ctx) => {
const detail = (await getProvider(ctx.params.source!).getArtist(ctx.params.id!)) ?? notFound('歌手');
ctx.body = markDownloaded(ctx.params.source!, detail);
});
api.get('/:source/lyric/:id', async (ctx) => {
ctx.body = (await getProvider(ctx.params.source!).getLyric(ctx.params.id!)) ?? notFound('歌词');
});
api.get('/:source/stream/:id', async (ctx) => {
await proxyStream(ctx, ctx.params.source!, ctx.params.id!);
});
// ---------- 下载队列 ----------
api.get('/tasks', (ctx) => {
const scope = ['active', 'finished'].includes(String(ctx.query.scope)) ? (ctx.query.scope as 'active' | 'finished') : 'all';
ctx.body = { tasks: queue.list(scope), progress: queue.progressSnapshot(), summary: queue.summary() };
});
api.post('/tasks', (ctx) => {
const { items, quality, group } = body(ctx);
if (!Array.isArray(items) || !items.length) ctx.throw(400, '没有要下载的歌曲');
const clean: EnqueueItem[] = items
.filter((i: any) => i && typeof i.id !== 'undefined' && (i.source === 'netease' || i.source === 'qq'))
.map((i: any) => ({
source: i.source as Source,
id: String(i.id),
name: i.name,
artists: i.artists,
album: i.album,
cover: i.cover,
}));
const { added, duplicates } = queue.enqueue(clean, { quality, group: typeof group === 'string' ? group : undefined });
ctx.body = { added: added.length, duplicates };
});
api.post('/tasks/:id/:action', (ctx) => {
const id = Number(ctx.params.id);
switch (ctx.params.action) {
case 'pause':
queue.pause(id);
break;
case 'resume':
queue.resume(id);
break;
case 'retry':
queue.retry(id);
break;
case 'cancel':
queue.cancel(id);
break;
default:
ctx.throw(404, '未知操作');
}
ctx.body = { ok: true };
});
api.delete('/tasks/:id', (ctx) => {
queue.remove(Number(ctx.params.id));
ctx.body = { ok: true };
});
api.post('/queue/:action', (ctx) => {
const action = ctx.params.action;
if (action === 'pause') queue.setPaused(true);
else if (action === 'resume') queue.setPaused(false);
else if (action === 'retry-failed') ctx.body = { retried: queue.retryFailed() };
else if (action === 'clear') ctx.body = { removed: queue.clearFinished(body(ctx).includeFailed === true) };
else ctx.throw(404, '未知操作');
ctx.body ??= queue.summary();
});
// ---------- 实时事件(SSE) ----------
api.get('/events', (ctx) => {
ctx.req.socket.setTimeout(0);
ctx.req.socket.setNoDelay(true);
ctx.req.socket.setKeepAlive(true);
ctx.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
const stream = new PassThrough();
ctx.status = 200;
ctx.body = stream;
const send = (event: string, data: unknown) => stream.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const handlers: Record<string, (data: unknown) => void> = {
task: (d) => send('task', d),
'task-removed': (d) => send('task-removed', d),
progress: (d) => send('progress', d),
queue: (d) => send('queue', d),
settings: (d) => send('settings', d),
account: (d) => send('account', d),
history: () => send('history', {}),
retag: (d) => send('retag', d),
};
for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn);
send('hello', { summary: queue.summary(), progress: queue.progressSnapshot() });
const ping = setInterval(() => stream.write(': ping\n\n'), 25000);
const cleanup = () => {
clearInterval(ping);
for (const [event, fn] of Object.entries(handlers)) bus.off(event, fn);
};
ctx.req.on('close', cleanup);
stream.on('error', cleanup);
});
// ---------- 历史 ----------
api.get('/history', (ctx) => {
ctx.body = listHistory(intParam(ctx.query.limit, 50, 500), Number(ctx.query.offset) || 0, {
q: String(ctx.query.q ?? '').trim() || undefined,
source: ctx.query.source ? String(ctx.query.source) : undefined,
});
});
api.get('/history/export', (ctx) => {
const rows = allHistory();
const stamp = new Date().toISOString().slice(0, 10);
if (ctx.query.format === 'csv') {
const cols = ['createdAt', 'source', 'trackId', 'name', 'artists', 'album', 'quality', 'bitrate', 'size', 'file'] as const;
const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const csv = [cols.join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))].join('\r\n');
ctx.set('Content-Disposition', `attachment; filename="history-${stamp}.csv"`);
ctx.type = 'text/csv; charset=utf-8';
ctx.body = '' + csv;
} else {
ctx.set('Content-Disposition', `attachment; filename="history-${stamp}.json"`);
ctx.body = rows;
}
});
api.get('/stats', (ctx) => {
ctx.body = historyStats();
});
// ---------- 曲库 ----------
api.get('/library', async (ctx) => {
ctx.body = await listDir(ctx.query.path ? String(ctx.query.path) : '');
});
api.get('/library/file', async (ctx) => {
const abs = resolveInLibrary(String(ctx.query.path ?? ''));
const stat = await fs.promises.stat(abs).catch(() => null);
if (!stat?.isFile()) throw Object.assign(new Error('文件不存在'), { status: 404 });
const ext = path.extname(abs).toLowerCase();
const types: Record<string, string> = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' };
ctx.type = types[ext] || 'application/octet-stream';
ctx.set('Accept-Ranges', 'bytes');
const range = ctx.get('range').match(/bytes=(\d*)-(\d*)/);
if (range && AUDIO_EXTS.has(ext)) {
const start = range[1] ? Number(range[1]) : 0;
const end = range[2] ? Math.min(Number(range[2]), stat.size - 1) : stat.size - 1;
ctx.status = 206;
ctx.set('Content-Range', `bytes ${start}-${end}/${stat.size}`);
ctx.length = end - start + 1;
ctx.body = fs.createReadStream(abs, { start, end });
} else {
ctx.length = stat.size;
ctx.body = fs.createReadStream(abs);
}
});
api.post('/library/m3u', async (ctx) => {
ctx.body = { file: await writeM3u(String(body(ctx).path ?? '')) };
});
api.get('/library/zip', async (ctx) => {
const { stream, filename } = await zipStream(String(ctx.query.path ?? ''));
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
ctx.type = 'application/zip';
ctx.body = stream;
});
api.post('/library/trash', async (ctx) => {
ctx.body = { moved: await moveToTrash(String(body(ctx).path ?? '')) };
});
api.get('/library/trash', async (ctx) => {
ctx.body = await trashInfo();
});
api.delete('/library/trash', async (ctx) => {
await emptyTrash();
ctx.body = { ok: true };
});
api.get('/library/retag', (ctx) => {
ctx.body = { job: currentRetagJob() };
});
api.post('/library/retag', async (ctx) => {
const { path: rel, source, onlyMissingLyric } = body(ctx);
ctx.body = await startRetag(String(rel ?? ''), source === 'qq' ? 'qq' : 'netease', onlyMissingLyric === true);
});
+51
View File
@@ -0,0 +1,51 @@
// 本机密钥:首次启动在数据目录生成,用于加密存储的凭据与签名登录会话
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { config } from './config.ts';
const KEY_FILE = path.join(config.dataDir, 'secret.key');
function loadKey(): Buffer {
try {
const hex = fs.readFileSync(KEY_FILE, 'utf-8').trim();
if (/^[0-9a-f]{64}$/i.test(hex)) return Buffer.from(hex, 'hex');
} catch {}
const key = crypto.randomBytes(32);
fs.writeFileSync(KEY_FILE, key.toString('hex'), { mode: 0o600 });
return key;
}
const KEY = loadKey();
export function encrypt(plain: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const data = Buffer.concat([cipher.update(plain, 'utf-8'), cipher.final()]);
return [iv, cipher.getAuthTag(), data].map((b) => b.toString('base64url')).join('.');
}
export function decrypt(payload: string): string {
const [iv, tag, data] = payload.split('.').map((s) => Buffer.from(s, 'base64url'));
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv!);
decipher.setAuthTag(tag!);
return Buffer.concat([decipher.update(data!), decipher.final()]).toString('utf-8');
}
export function sign(value: string): string {
return crypto.createHmac('sha256', KEY).update(value).digest('base64url');
}
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(password, salt, 32);
return `${salt.toString('base64url')}.${hash.toString('base64url')}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split('.');
if (!salt || !hash) return false;
const expected = Buffer.from(hash, 'base64url');
const actual = crypto.scryptSync(password, Buffer.from(salt, 'base64url'), expected.length);
return crypto.timingSafeEqual(actual, expected);
}
+40
View File
@@ -0,0 +1,40 @@
// 运行时设置:config.json 提供默认值,网页上修改后存入 SQLite
import { config, type DownloadDefaults } from './config.ts';
import { isQualityKey } from './core/quality.ts';
import { getSetting, setSetting } from './db.ts';
import { bus } from './events.ts';
export interface Settings extends DownloadDefaults {
concurrency: number;
/** 批量任务之间的间隔,避免请求过快 */
gapMs: number;
}
const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400 };
let current: Settings = { ...BASE, ...getSetting<Partial<Settings>>('settings', {}) };
export function getSettings(): Settings {
return current;
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const n = Math.round(Number(value));
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
}
export function updateSettings(patch: Partial<Record<keyof Settings, unknown>>): Settings {
const next = { ...current };
if (patch.quality !== undefined && isQualityKey(patch.quality)) next.quality = patch.quality;
if (patch.layout === 'album' || patch.layout === 'flat') next.layout = patch.layout;
if (patch.retries !== undefined) next.retries = clampInt(patch.retries, 1, 10, next.retries);
if (patch.concurrency !== undefined) next.concurrency = clampInt(patch.concurrency, 1, 6, next.concurrency);
if (patch.gapMs !== undefined) next.gapMs = clampInt(patch.gapMs, 0, 5000, next.gapMs);
for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover'] as const) {
if (typeof patch[key] === 'boolean') next[key] = patch[key] as boolean;
}
current = next;
setSetting('settings', current);
bus.emit('settings', current);
return current;
}
+34
View File
@@ -0,0 +1,34 @@
// 试听代理:解析出 320K 音源后由服务端转发(支持 Range),绕过防盗链与混合内容
import { Readable } from 'node:stream';
import type { Context } from 'koa';
import { UA } from './core/net/http.ts';
import { getProvider } from './core/providers/index.ts';
import { TtlCache } from './core/util.ts';
const urlCache = new TtlCache<{ url: string; ext: string }>(3 * 60 * 1000, 200);
const MIME: Record<string, string> = { mp3: 'audio/mpeg', flac: 'audio/flac', m4a: 'audio/mp4', ogg: 'audio/ogg' };
export async function proxyStream(ctx: Context, source: string, id: string) {
const provider = getProvider(source);
const audio = await urlCache.getOrSet(`${source}:${id}`, async () => {
const r = await provider.resolveAudio(id, 'high');
return { url: r.url, ext: r.ext };
});
const controller = new AbortController();
ctx.req.on('close', () => controller.abort());
const headers: Record<string, string> = { 'User-Agent': UA };
if (ctx.get('range')) headers.Range = ctx.get('range');
const upstream = await fetch(audio.url, { headers, signal: controller.signal });
if (!upstream.ok || !upstream.body) ctx.throw(502, `音源请求失败 (HTTP ${upstream.status})`);
ctx.status = upstream.status;
ctx.set('Content-Type', MIME[audio.ext] || upstream.headers.get('content-type') || 'application/octet-stream');
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Cache-Control', 'no-store');
for (const h of ['content-length', 'content-range']) {
const v = upstream.headers.get(h);
if (v) ctx.set(h, v);
}
ctx.body = Readable.fromWeb(upstream.body as any);
}