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
+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 });