feat: 声匣 —— 网易云 / QQ 音乐自托管下载服务
由 163-music-dl CLI 重构为 Web 服务: - 下载流水线、歌词与标签逻辑沿用旧版(node-id3 / ffmpeg) - 网易云(chksz 接口 + music.163.com 元数据)与 QQ 音乐(VIP Cookie)provider - QQ 扫码登录与 musickey 自动续期 - SQLite 持久化的下载队列,SSE 实时进度 - 曲库工具(M3U / ZIP / 回收站 / 补全标签)、历史统计与导出、访问密码 - Vite 8 + Vue 3 前端,浅色 / 深色 - Docker 部署配置(飞牛 NAS) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
Reference in New Issue
Block a user