676541457b
- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage - 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件 - 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
7.7 KiB
TypeScript
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;
|
|
}
|