21945cc54f
由 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>
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
// 下载队列:任务持久化在 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());
|