// 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); CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS playlist_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, position INTEGER NOT NULL, item_key TEXT NOT NULL, source TEXT, track_id TEXT, path TEXT, name TEXT NOT NULL, artists TEXT NOT NULL DEFAULT '', album TEXT NOT NULL DEFAULT '', album_id TEXT, cover TEXT, duration INTEGER, added_at TEXT NOT NULL, UNIQUE (playlist_id, item_key) ); CREATE INDEX IF NOT EXISTS idx_playlist_items_pos ON playlist_items(playlist_id, position); `); 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 & { 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 { 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(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 = { 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; }