Files
music-hub/server/src/db.ts
T
Jocay 676541457b feat: P7 在线播放(播放队列、媒体控制)与自建歌单
- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage
- 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件
- 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:31:29 +08:00

235 lines
7.7 KiB
TypeScript

// 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<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;
}