diff --git a/components.d.ts b/components.d.ts index 954a07e2..3f11b156 100644 --- a/components.d.ts +++ b/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { AutoCloseDialog: typeof import('./src/components/modals/AutoCloseDialog.vue')['default'] BackgroundImagePicker: typeof import('./src/components/settings/custom/BackgroundImagePicker.vue')['default'] BottomSpectrum: typeof import('./src/components/player/FullPlayer/BottomSpectrum.vue')['default'] + CacheManager: typeof import('./src/components/settings/custom/CacheManager.vue')['default'] ComboboxAnchor: typeof import('reka-ui')['ComboboxAnchor'] ComboboxContent: typeof import('reka-ui')['ComboboxContent'] ComboboxEmpty: typeof import('reka-ui')['ComboboxEmpty'] @@ -37,6 +38,7 @@ declare module 'vue' { ContextMenuSubTrigger: typeof import('reka-ui')['ContextMenuSubTrigger'] ContextMenuTrigger: typeof import('reka-ui')['ContextMenuTrigger'] CoverList: typeof import('./src/components/list/CoverList.vue')['default'] + DbCacheManager: typeof import('./src/components/settings/custom/DbCacheManager.vue')['default'] DeviceSelector: typeof import('./src/components/settings/custom/DeviceSelector.vue')['default'] DialogClose: typeof import('reka-ui')['DialogClose'] DialogContent: typeof import('reka-ui')['DialogContent'] @@ -56,6 +58,7 @@ declare module 'vue' { DropdownMenuTrigger: typeof import('reka-ui')['DropdownMenuTrigger'] EqualizerDialog: typeof import('./src/components/modals/EqualizerDialog.vue')['default'] ExcludeLyricsConfig: typeof import('./src/components/settings/custom/ExcludeLyricsConfig.vue')['default'] + FileCacheManager: typeof import('./src/components/settings/custom/FileCacheManager.vue')['default'] FontConfig: typeof import('./src/components/settings/custom/FontConfig.vue')['default'] FullPlayer: typeof import('./src/components/player/FullPlayer/index.vue')['default'] HotkeyConfig: typeof import('./src/components/settings/custom/HotkeyConfig.vue')['default'] @@ -90,6 +93,7 @@ declare module 'vue' { IconLucideMusic: typeof import('~icons/lucide/music')['default'] IconLucidePause: typeof import('~icons/lucide/pause')['default'] IconLucidePlay: typeof import('~icons/lucide/play')['default'] + IconLucidePlus: typeof import('~icons/lucide/plus')['default'] IconLucidePower: typeof import('~icons/lucide/power')['default'] IconLucidePuzzle: typeof import('~icons/lucide/puzzle')['default'] IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default'] @@ -98,6 +102,7 @@ declare module 'vue' { IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearchX: typeof import('~icons/lucide/search-x')['default'] IconLucideSettings: typeof import('~icons/lucide/settings')['default'] + IconLucideSettings2: typeof import('~icons/lucide/settings2')['default'] IconLucideShuffle: typeof import('~icons/lucide/shuffle')['default'] IconLucideSkipBack: typeof import('~icons/lucide/skip-back')['default'] IconLucideSkipForward: typeof import('~icons/lucide/skip-forward')['default'] @@ -115,6 +120,10 @@ declare module 'vue' { Lyrics: typeof import('./src/components/player/Lyrics/index.vue')['default'] LyricSourceOrderConfig: typeof import('./src/components/settings/custom/LyricSourceOrderConfig.vue')['default'] NavHeader: typeof import('./src/layouts/components/NavHeader.vue')['default'] + NumberFieldDecrement: typeof import('reka-ui')['NumberFieldDecrement'] + NumberFieldIncrement: typeof import('reka-ui')['NumberFieldIncrement'] + NumberFieldInput: typeof import('reka-ui')['NumberFieldInput'] + NumberFieldRoot: typeof import('reka-ui')['NumberFieldRoot'] PlayerBackground: typeof import('./src/components/player/FullPlayer/PlayerBackground.vue')['default'] PlayerBar: typeof import('./src/components/player/PlayerBar.vue')['default'] PlayerControls: typeof import('./src/components/player/PlayerControls.vue')['default'] @@ -166,6 +175,8 @@ declare module 'vue' { SLogo: typeof import('./src/components/ui/SLogo.vue')['default'] SMarquee: typeof import('./src/components/ui/SMarquee.vue')['default'] SMenu: typeof import('./src/components/ui/SMenu.vue')['default'] + SNumberInput: typeof import('./src/components/ui/SNumberInput.vue')['default'] + SongCacheSizeLimit: typeof import('./src/components/settings/custom/SongCacheSizeLimit.vue')['default'] SongList: typeof import('./src/components/list/SongList.vue')['default'] SpeedDialog: typeof import('./src/components/modals/SpeedDialog.vue')['default'] SPopover: typeof import('./src/components/ui/SPopover.vue')['default'] diff --git a/electron/main/apis/musicbrainz.ts b/electron/main/apis/musicbrainz.ts index 374d96b0..4953a0b8 100644 --- a/electron/main/apis/musicbrainz.ts +++ b/electron/main/apis/musicbrainz.ts @@ -1,7 +1,7 @@ import { net } from "electron"; import fs from "node:fs"; import path from "node:path"; -import { artistCacheDir } from "@main/utils/config"; +import { getArtistCacheDir } from "@main/utils/config"; import { toCacheUrl } from "@main/utils/protocol"; /** MusicBrainz User-Agent */ @@ -34,8 +34,8 @@ const getNotFoundFileName = (artistName: string): string => { /** 确保缓存目录存在 */ const ensureCacheDir = (): void => { - if (!fs.existsSync(artistCacheDir)) { - fs.mkdirSync(artistCacheDir, { recursive: true }); + if (!fs.existsSync(getArtistCacheDir())) { + fs.mkdirSync(getArtistCacheDir(), { recursive: true }); } }; @@ -92,13 +92,13 @@ const fetchArtistAvatarCore = async (artistName: string): Promise // 确保目录存在 ensureCacheDir(); const cacheFileName = getCacheFileName(name); - const cachePath = path.join(artistCacheDir, cacheFileName); + const cachePath = path.join(getArtistCacheDir(), cacheFileName); // 已缓存 if (fs.existsSync(cachePath)) { return toCacheUrl(cachePath) ?? null; } // 已标记为未找到(7 天过期) - const notFoundPath = path.join(artistCacheDir, getNotFoundFileName(name)); + const notFoundPath = path.join(getArtistCacheDir(), getNotFoundFileName(name)); if (fs.existsSync(notFoundPath)) { const stat = fs.statSync(notFoundPath); if (Date.now() - stat.mtimeMs < 7 * 24 * 60 * 60 * 1000) return null; diff --git a/electron/main/core/index.ts b/electron/main/core/index.ts index 6a4d4639..56078891 100644 --- a/electron/main/core/index.ts +++ b/electron/main/core/index.ts @@ -5,6 +5,7 @@ import { registerIpcHandlers } from "@main/ipc"; import { init as initMedia, shutdown as shutdownMedia } from "@main/services/media"; import { initGlobalHotkey } from "@main/services/globalHotkey"; import { initDatabase, closeDatabase } from "@main/database"; +import { init as initSongCache } from "@main/services/songCache"; import { pluginRegistry } from "@main/plugins/registry"; import { registerCacheScheme, handleCacheProtocol } from "@main/utils/protocol"; import { coreLog, initLogger } from "@main/utils/logger"; @@ -58,6 +59,8 @@ export const initApp = (): void => { }); // 初始化数据库 initDatabase(); + // 启动歌曲缓存服务 + initSongCache(); // 注册 IPC registerIpcHandlers(); // 初始化系统媒体控件 diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index 983fa2e6..9d7d8ddc 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -81,6 +81,18 @@ export const initDatabase = (): void => { fetched_at INTEGER NOT NULL, PRIMARY KEY (platform, id) ); + + CREATE TABLE IF NOT EXISTS song_cache ( + cache_key TEXT PRIMARY KEY, + source TEXT NOT NULL, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + mime TEXT, + cached_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_song_cache_last_used ON song_cache(last_used_at); + CREATE INDEX IF NOT EXISTS idx_song_cache_source ON song_cache(source); `); migrate(db); libraryLog.info(`数据库已初始化: ${dbPath}`); diff --git a/electron/main/database/songCache.ts b/electron/main/database/songCache.ts new file mode 100644 index 00000000..ff096ae2 --- /dev/null +++ b/electron/main/database/songCache.ts @@ -0,0 +1,133 @@ +import type { TrackSource } from "@shared/types/player"; +import { getDb } from "./index"; + +/** 单条记录 */ +export interface SongCacheRow { + cacheKey: string; + source: TrackSource; + filename: string; + size: number; + mime: string | null; + cachedAt: number; + lastUsedAt: number; +} + +/** sqlite 原始行 */ +interface RawRow { + cache_key: string; + source: TrackSource; + filename: string; + size: number; + mime: string | null; + cached_at: number; + last_used_at: number; +} + +/** sqlite snake_case 行 → 业务 camelCase */ +const toRow = (raw: RawRow): SongCacheRow => ({ + cacheKey: raw.cache_key, + source: raw.source, + filename: raw.filename, + size: raw.size, + mime: raw.mime, + cachedAt: raw.cached_at, + lastUsedAt: raw.last_used_at, +}); + +/** + * 按 key 查记录 + * @param cacheKey - 缓存键 + * @returns 记录 + */ +export const findByKey = (cacheKey: string): SongCacheRow | null => { + const raw = getDb().prepare("SELECT * FROM song_cache WHERE cache_key = ?").get(cacheKey) as + | RawRow + | undefined; + return raw ? toRow(raw) : null; +}; + +/** + * 按 filename 反查 + * @param filename - 文件名 + * @returns 记录 + */ +export const findByFilename = (filename: string): SongCacheRow | null => { + const raw = getDb().prepare("SELECT * FROM song_cache WHERE filename = ?").get(filename) as + | RawRow + | undefined; + return raw ? toRow(raw) : null; +}; + +/** + * 更新最近访问时间(命中 LRU) + * @param cacheKey - 缓存键 + * @param now - 当前时间 + */ +export const touchLastUsed = (cacheKey: string, now: number): void => { + getDb().prepare("UPDATE song_cache SET last_used_at = ? WHERE cache_key = ?").run(now, cacheKey); +}; + +/** + * 写入或覆盖一条记录 + * @param row - 记录 + */ +export const upsert = (row: SongCacheRow): void => { + getDb() + .prepare( + `INSERT INTO song_cache (cache_key, source, filename, size, mime, cached_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(cache_key) DO UPDATE SET + source = excluded.source, + filename = excluded.filename, + size = excluded.size, + mime = excluded.mime, + cached_at = excluded.cached_at, + last_used_at = excluded.last_used_at`, + ) + .run(row.cacheKey, row.source, row.filename, row.size, row.mime, row.cachedAt, row.lastUsedAt); +}; + +/** + * 删除一条 + * @param cacheKey - 缓存键 + */ +export const deleteByKey = (cacheKey: string): void => { + getDb().prepare("DELETE FROM song_cache WHERE cache_key = ?").run(cacheKey); +}; + +/** + * 当前总占用字节数 + * @returns 总占用字节数 + */ +export const totalSize = (): number => { + const row = getDb().prepare("SELECT SUM(size) AS total FROM song_cache").get() as + | { total: number | null } + | undefined; + return row?.total ?? 0; +}; + +/** + * 列出全部 filename + * @returns 文件名列表 + */ +export const listAllFilenames = (): string[] => { + const rows = getDb().prepare("SELECT filename FROM song_cache").all() as { filename: string }[]; + return rows.map((entry) => entry.filename); +}; + +/** + * LRU 淘汰候选:按 last_used_at 升序取前 limit 条 + * @param limit - 限制数量 + * @returns 记录列表 + */ +export const listLruVictims = (limit: number): SongCacheRow[] => { + const rows = getDb() + .prepare("SELECT * FROM song_cache ORDER BY last_used_at ASC LIMIT ?") + .all(limit) as RawRow[]; + return rows.map(toRow); +}; + +/** 清空整张表 */ +export const clearAll = (): void => { + getDb().prepare("DELETE FROM song_cache").run(); +}; diff --git a/electron/main/ipc/cache.ts b/electron/main/ipc/cache.ts new file mode 100644 index 00000000..16b26165 --- /dev/null +++ b/electron/main/ipc/cache.ts @@ -0,0 +1,269 @@ +import { dialog, ipcMain } from "electron"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { existsSync, type Dirent } from "node:fs"; +import { store } from "@main/store"; +import { + defaultAppCacheDir, + getAppCacheDir, + getCoverCacheDir, + getArtistCacheDir, + getBackgroundsDir, + getSongCacheDir, +} from "@main/utils/config"; +import { syncCoverCacheDir } from "@main/services/engine"; +import { getDb } from "@main/database"; +import { clearLyricCache } from "@main/database/lyricCache"; +import { clearLyricTtmlCache } from "@main/database/lyricTtmlCache"; +import { clearLyricMatchCache } from "@main/database/lyricMatchCache"; +import * as songCache from "@main/services/songCache"; +import type { TrackSource } from "@shared/types/player"; +import { systemLog } from "@main/utils/logger"; + +/** 已知的缓存类别 */ +export type CacheCategory = + | "covers" + | "artists" + | "backgrounds" + | "songs" + | "lyric" + | "lyricTTML" + | "lyricMatch"; + +/** 缓存介质 */ +export type CacheKind = "file" | "db"; + +/** 单类别的占用情况 */ +export interface CacheStat { + id: CacheCategory; + kind: CacheKind; + /** 显示用路径或来源 */ + path: string; + size: number; +} + +/** + * 递归累计目录占用 + * @param dir - 目录路径 + * @returns 占用字节数 + */ +const dirSize = async (dir: string): Promise => { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return 0; + } + const sizes = await Promise.all( + entries.map(async (entry) => { + const full = path.join(dir, entry.name); + try { + if (entry.isDirectory()) return await dirSize(full); + if (entry.isFile()) return (await fs.stat(full)).size; + } catch {} + return 0; + }), + ); + return sizes.reduce((sum, item) => sum + item, 0); +}; + +/** + * 清空目录但保留目录本身 + * @param dir - 目录路径 + */ +const clearDir = async (dir: string): Promise => { + if (!existsSync(dir)) return; + const entries = await fs.readdir(dir); + await Promise.all( + entries.map((name) => fs.rm(path.join(dir, name), { recursive: true, force: true })), + ); +}; + +/** + * 目录是否为空(不存在视为空) + * @param dir - 目录路径 + * @returns 是否为空 + */ +const isDirEmpty = async (dir: string): Promise => { + if (!existsSync(dir)) return true; + const entries = await fs.readdir(dir); + return entries.length === 0; +}; + +/** + * sqlite 单表占用:取所有 TEXT/BLOB 字段 length 之和 + * @param table - 表名 + * @param columns - 列名列表 + * @returns 占用字节数 + */ +const tableSize = (table: string, columns: string[]): number => { + try { + const expr = columns.map((c) => `COALESCE(length(${c}), 0)`).join(" + "); + const row = getDb().prepare(`SELECT SUM(${expr}) AS total FROM ${table}`).get() as + | { total: number | null } + | undefined; + return row?.total ?? 0; + } catch { + return 0; + } +}; + +/** + * 类别 → 介质 / 占用统计 / 路径展示 / 清空动作 + * @param category - 类别 + * @returns 介质 / 占用统计 / 路径展示 / 清空动作 + */ +const categoryHandlers: Record< + CacheCategory, + { + kind: CacheKind; + path: () => string; + size: () => number | Promise; + clear: () => void | Promise; + } +> = { + covers: { + kind: "file", + path: getCoverCacheDir, + size: () => dirSize(getCoverCacheDir()), + clear: () => clearDir(getCoverCacheDir()), + }, + artists: { + kind: "file", + path: getArtistCacheDir, + size: () => dirSize(getArtistCacheDir()), + clear: () => clearDir(getArtistCacheDir()), + }, + backgrounds: { + kind: "file", + path: getBackgroundsDir, + size: () => dirSize(getBackgroundsDir()), + clear: () => clearDir(getBackgroundsDir()), + }, + songs: { + kind: "file", + path: getSongCacheDir, + size: () => songCache.stats().size, + clear: () => songCache.clearAll(), + }, + lyric: { + kind: "db", + path: () => "lyric_cache", + size: () => tableSize("lyric_cache", ["data"]), + clear: clearLyricCache, + }, + lyricTTML: { + kind: "db", + path: () => "lyric_ttml_cache", + size: () => tableSize("lyric_ttml_cache", ["content"]), + clear: clearLyricTtmlCache, + }, + lyricMatch: { + kind: "db", + path: () => "lyric_match_cache", + size: () => tableSize("lyric_match_cache", ["fingerprint", "platform_id", "extra"]), + clear: clearLyricMatchCache, + }, +}; + +/** + * 按介质类型获取类别列表 + * @param kind - 介质类型 + * @returns 类别列表 + */ +const idsByKind = (kind: CacheKind): CacheCategory[] => + (Object.keys(categoryHandlers) as CacheCategory[]).filter( + (id) => categoryHandlers[id].kind === kind, + ); + +/** 注册缓存相关 IPC */ +export const registerCacheIpc = (): void => { + ipcMain.handle("cache:getStats", async (): Promise => { + const ids = Object.keys(categoryHandlers) as CacheCategory[]; + return Promise.all( + ids.map(async (id) => ({ + id, + kind: categoryHandlers[id].kind, + path: categoryHandlers[id].path(), + size: await categoryHandlers[id].size(), + })), + ); + }); + + ipcMain.handle("cache:clear", async (_event, id: CacheCategory): Promise => { + const handler = categoryHandlers[id]; + if (!handler) return; + try { + await handler.clear(); + systemLog.info(`[cache] cleared ${id}`); + } catch (err) { + systemLog.error(`[cache] clear ${id} failed`, err); + throw err; + } + }); + + /** 一键清空:按介质分桶,避免误删另一类 */ + ipcMain.handle("cache:clearAllByKind", async (_event, kind: CacheKind): Promise => { + const ids = idsByKind(kind); + await Promise.all(ids.map((id) => categoryHandlers[id].clear())); + systemLog.info(`[cache] cleared all (${kind})`); + }); + + ipcMain.handle("cache:getDir", (): string => getAppCacheDir()); + + /** + * 切换缓存目录 + * - 用户必须选择空目录才允许切换 + * - 切换前清空旧目录下的文件类缓存 + */ + ipcMain.handle( + "cache:pickDir", + async (): Promise<{ ok: boolean; dir: string; reason?: "canceled" | "notEmpty" }> => { + const current = getAppCacheDir(); + const result = await dialog.showOpenDialog({ + title: "选择缓存目录", + properties: ["openDirectory", "createDirectory"], + }); + if (result.canceled || result.filePaths.length === 0) { + return { ok: false, dir: current, reason: "canceled" }; + } + const next = result.filePaths[0]; + if (!(await isDirEmpty(next))) { + return { ok: false, dir: current, reason: "notEmpty" }; + } + await Promise.all(idsByKind("file").map((id) => categoryHandlers[id].clear())); + store.set("cache.dir", next); + syncCoverCacheDir(); + songCache.reloadDir(); + systemLog.info(`[cache] dir switched to ${next}`); + return { ok: true, dir: next }; + }, + ); + + /** 还原默认缓存目录(同样清空旧的文件类缓存) */ + ipcMain.handle("cache:resetDir", async (): Promise => { + await Promise.all(idsByKind("file").map((id) => categoryHandlers[id].clear())); + store.set("cache.dir", null); + syncCoverCacheDir(); + songCache.reloadDir(); + return defaultAppCacheDir; + }); + + /** 歌曲文件级缓存:命中查询 */ + ipcMain.handle( + "cache:song:lookup", + (_event, cacheKey: string): Promise => songCache.lookup(cacheKey), + ); + + /** 歌曲文件级缓存:排队下载 */ + ipcMain.handle( + "cache:song:fetch", + (_event, cacheKey: string, source: TrackSource, streamUrl: string): Promise => + songCache.fetchAsync(cacheKey, source, streamUrl), + ); + + /** 歌曲文件级缓存:取消进行中的下载 */ + ipcMain.handle("cache:song:cancel", (_event, cacheKey: string): void => { + songCache.cancel(cacheKey); + }); +}; diff --git a/electron/main/ipc/config.ts b/electron/main/ipc/config.ts index e784cdae..78484655 100644 --- a/electron/main/ipc/config.ts +++ b/electron/main/ipc/config.ts @@ -1,6 +1,8 @@ -import { ipcMain } from "electron"; +import fs from "node:fs/promises"; +import { dialog, ipcMain } from "electron"; import { store } from "@main/store"; import type { ConfigPath } from "@main/store/types"; +import { systemLog } from "@main/utils/logger"; import { enable as enableMedia, disable as disableMedia, @@ -94,4 +96,63 @@ export const registerConfigIpc = (): void => { }); ipcMain.handle("config:getAll", () => store.store); ipcMain.handle("config:reset", () => store.clear()); + + /** 替换整盘配置 */ + ipcMain.handle("config:replaceAll", (_event, payload: unknown) => { + store.replaceAll(payload); + }); + + /** 备份 */ + ipcMain.handle( + "config:exportToFile", + async ( + _event, + payload: unknown, + ): Promise<{ ok: boolean; reason?: "canceled" | "writeFailed" }> => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const result = await dialog.showSaveDialog({ + title: "导出设置备份", + defaultPath: `splayer-settings-${stamp}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (result.canceled || !result.filePath) return { ok: false, reason: "canceled" }; + try { + await fs.writeFile(result.filePath, JSON.stringify(payload, null, 2), "utf-8"); + systemLog.info(`[config] settings exported to ${result.filePath}`); + return { ok: true }; + } catch (err) { + systemLog.error("[config] exportToFile failed", err); + return { ok: false, reason: "writeFailed" }; + } + }, + ); + + /** 恢复 */ + ipcMain.handle( + "config:importFromFile", + async (): Promise< + { ok: true; data: unknown } | { ok: false; reason: "canceled" | "readFailed" | "parseFailed" } + > => { + const result = await dialog.showOpenDialog({ + title: "选择设置备份文件", + filters: [{ name: "JSON", extensions: ["json"] }], + properties: ["openFile"], + }); + if (result.canceled || result.filePaths.length === 0) { + return { ok: false, reason: "canceled" }; + } + try { + const text = await fs.readFile(result.filePaths[0], "utf-8"); + try { + return { ok: true, data: JSON.parse(text) }; + } catch (err) { + systemLog.error("[config] importFromFile parse failed", err); + return { ok: false, reason: "parseFailed" }; + } + } catch (err) { + systemLog.error("[config] importFromFile read failed", err); + return { ok: false, reason: "readFailed" }; + } + }, + ); }; diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index ea4f49d8..ba248ec8 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -10,6 +10,7 @@ import { registerLyricsIpc } from "./lyrics"; import { registerHotkeyIpc } from "./hotkey"; import { registerThemeIpc } from "./theme"; import { registerStreamingIpc } from "./streaming"; +import { registerCacheIpc } from "./cache"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -25,4 +26,5 @@ export const registerIpcHandlers = (): void => { registerHotkeyIpc(); registerThemeIpc(); registerStreamingIpc(); + registerCacheIpc(); }; diff --git a/electron/main/ipc/nowPlaying.ts b/electron/main/ipc/nowPlaying.ts index 0da4d77b..4baa2a4b 100644 --- a/electron/main/ipc/nowPlaying.ts +++ b/electron/main/ipc/nowPlaying.ts @@ -9,6 +9,11 @@ export const registerNowPlayingIpc = (): void => { nowPlaying.update(payload.track, payload.lyric, payload.source); }); + // 渲染进程写入指定曲目的歌词偏移 + ipcMain.on("nowPlaying:setLyricOffset", (_event, trackId: string, offsetMs: number) => { + nowPlaying.setLyricOffset(trackId, offsetMs); + }); + // 窗口拉取当前完整快照 ipcMain.handle("nowPlaying:requestSnapshot", () => nowPlaying.snapshot()); @@ -16,4 +21,5 @@ export const registerNowPlayingIpc = (): void => { nowPlaying.onTrackChange((data) => broadcast("nowPlaying:track-change", data)); nowPlaying.onLyricChange((snap) => broadcast("nowPlaying:lyric-change", snap)); nowPlaying.onPositionSync((data) => broadcast("nowPlaying:position-sync", data)); + nowPlaying.onLyricOffsetChange((data) => broadcast("nowPlaying:lyric-offset-change", data)); }; diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index e2fe2348..e2bac773 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -12,7 +12,8 @@ import { getThumbar } from "@main/services/thumbar"; import { setTraySongName, setTrayPlayState, setTrayPlayMode } from "@main/services/tray"; import { getMainWindow, setTaskbarProgress } from "@main/window"; import { store } from "@main/store"; -import { appName } from "@main/utils/config"; +import { appName, getSongCacheDir } from "@main/utils/config"; +import * as songCache from "@main/services/songCache"; import { parseArtists, parseAlbum, formatArtists } from "@main/utils/metadata"; import { playerLog } from "@main/utils/logger"; import { ErrorCode } from "@shared/types/errors"; @@ -216,6 +217,10 @@ export const registerPlayerIpc = (): void => { : isNetwork ? ErrorCode.NETWORK_ERROR : ErrorCode.FILE_DECODE_ERROR; + // 解码失败的源指向歌曲缓存目录 → 文件已损坏,把这条缓存项作废 + if (code === ErrorCode.FILE_DECODE_ERROR && source.startsWith(getSongCacheDir())) { + void songCache.invalidate(source); + } return fail(code, error); } }); diff --git a/electron/main/ipc/system.ts b/electron/main/ipc/system.ts index 550a7957..9c82776f 100644 --- a/electron/main/ipc/system.ts +++ b/electron/main/ipc/system.ts @@ -1,4 +1,4 @@ -import { ipcMain, shell } from "electron"; +import { app, ipcMain, shell } from "electron"; import { getFonts } from "font-list"; import type { LocaleCode } from "@shared/types/settings"; import { setLocale } from "@main/utils/i18n"; @@ -58,6 +58,12 @@ export const registerSystemIpc = (): void => { return fontsCache; }); + // 重启应用 + ipcMain.handle("system:relaunch", () => { + app.relaunch(); + app.exit(0); + }); + // 把任意 http(s) URL 拉成字节回渲染层 // 用于 canvas 取色等需要绕过跨域 tainted 的场景;不限流媒体 ipcMain.handle("system:fetchRemoteBytes", async (_event, url: string) => { diff --git a/electron/main/ipc/theme.ts b/electron/main/ipc/theme.ts index b3549aeb..75053844 100644 --- a/electron/main/ipc/theme.ts +++ b/electron/main/ipc/theme.ts @@ -2,13 +2,10 @@ import { dialog, ipcMain } from "electron"; import path from "node:path"; import fs from "node:fs/promises"; import { createHash } from "node:crypto"; -import { appCacheDir } from "@main/utils/config"; +import { getBackgroundsDir } from "@main/utils/config"; import { toCacheUrl } from "@main/utils/protocol"; import { systemLog } from "@main/utils/logger"; -/** 背景图片缓存目录 */ -const bgDir = path.join(appCacheDir, "backgrounds"); - /** 允许的图片扩展名 */ const allowedExt = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]); @@ -47,9 +44,9 @@ export const registerThemeIpc = (): void => { // 内容 hash 作为文件名 const hash = createHash("sha1").update(data).digest("hex").slice(0, 16); // 清空整个目录再写入新图 - await fs.rm(bgDir, { recursive: true, force: true }); - await fs.mkdir(bgDir, { recursive: true }); - const dest = path.join(bgDir, `${hash}${ext}`); + await fs.rm(getBackgroundsDir(), { recursive: true, force: true }); + await fs.mkdir(getBackgroundsDir(), { recursive: true }); + const dest = path.join(getBackgroundsDir(), `${hash}${ext}`); await fs.writeFile(dest, data); return toCacheUrl(dest) ?? null; } catch (err) { @@ -63,7 +60,7 @@ export const registerThemeIpc = (): void => { */ ipcMain.handle("theme:clearBackgroundImages", async (): Promise => { try { - await fs.rm(bgDir, { recursive: true, force: true }); + await fs.rm(getBackgroundsDir(), { recursive: true, force: true }); } catch (err) { systemLog.error("[theme] clearBackgroundImages failed", err); } diff --git a/electron/main/services/engine.ts b/electron/main/services/engine.ts index c2c2f9cc..ce2b5704 100644 --- a/electron/main/services/engine.ts +++ b/electron/main/services/engine.ts @@ -1,5 +1,5 @@ import { loadNativeModule } from "@main/utils/nativeLoader"; -import { coverCacheDir, isDev } from "@main/utils/config"; +import { getCoverCacheDir, isDev } from "@main/utils/config"; import { playerLog, nativeLogsDir } from "@main/utils/logger"; type AudioEngineModule = typeof import("@splayer/audio-engine"); @@ -36,7 +36,7 @@ export const getPlayer = (): PlayerInstance => { if (!playerInstance) { const mod = getEngine(); playerInstance = new mod.AudioPlayer(); - playerInstance.setCoverCacheDir(coverCacheDir); + playerInstance.setCoverCacheDir(getCoverCacheDir()); for (const cb of onCreatedCallbacks) { cb(playerInstance); } @@ -85,3 +85,10 @@ export const setPreampGain = (preampDb: number): void => { playerInstance.setPreampGain(preampDb); } }; + +/** 同步当前封面缓存目录到原生引擎(缓存路径切换时调用) */ +export const syncCoverCacheDir = (): void => { + if (playerInstance) { + playerInstance.setCoverCacheDir(getCoverCacheDir()); + } +}; diff --git a/electron/main/services/nowPlaying.ts b/electron/main/services/nowPlaying.ts index 194ad283..18dc0263 100644 --- a/electron/main/services/nowPlaying.ts +++ b/electron/main/services/nowPlaying.ts @@ -1,7 +1,12 @@ import { EventEmitter } from "node:events"; import type { Track } from "@shared/types/player"; import type { LyricLine, LyricData } from "@shared/types/lyrics"; -import type { NowPlayingSnapshot, NowPlayingPositionSync } from "@shared/types/nowPlaying"; +import type { + NowPlayingSnapshot, + NowPlayingPositionSync, + NowPlayingLyricOffsetSync, +} from "@shared/types/nowPlaying"; +import { store } from "@main/store"; type NowPlayingEvents = { /** 歌曲切换 */ @@ -10,6 +15,8 @@ type NowPlayingEvents = { "lyric-change": [NowPlayingSnapshot]; /** 播放位置锚点 */ "position-sync": [NowPlayingPositionSync]; + /** 当前曲目歌词偏移变化 */ + "lyric-offset-change": [NowPlayingLyricOffsetSync]; }; /** 当前歌曲轻量信息 */ @@ -22,17 +29,33 @@ let currentSource: LyricData = null; let lastPosition = 0; /** 当前是否处于播放态 */ let playing = false; +/** 当前曲目对应的歌词偏移(ms,正值为歌词提前) */ +let currentLyricOffsetMs = 0; /** 内部事件总线 */ const emitter = new EventEmitter(); +/** 从存储中读取指定曲目的偏移;缺省视为 0 */ +const readOffset = (trackId: string | null | undefined): number => { + if (!trackId) return 0; + return store.get("player.lyricOffsets")?.[trackId] ?? 0; +}; + /** 渲染进程同步当前播放状态 */ export const update = (track: Track | null, lyric: LyricLine[], source: LyricData): void => { const trackChanged = (currentTrack?.id ?? null) !== (track?.id ?? null); currentTrack = track; currentLyric = lyric; currentSource = source; - if (trackChanged) emitter.emit("track-change", { track }); + if (trackChanged) { + // 切歌:加载新曲目的偏移并立即广播 + currentLyricOffsetMs = readOffset(track?.id); + emitter.emit("track-change", { track }); + emitter.emit("lyric-offset-change", { + trackId: track?.id ?? null, + offsetMs: currentLyricOffsetMs, + }); + } emitter.emit("lyric-change", snapshot()); }; @@ -57,6 +80,31 @@ export const onPlayStateChange = (isPlaying: boolean): void => { }); }; +/** 单曲偏移的合理上限(毫秒);超过视为误输入,clamp 防止极端值 */ +const LYRIC_OFFSET_LIMIT_MS = 60_000; + +/** + * 写入指定曲目的歌词偏移;0 视为清除 + * 同时持久化到 store,并在影响当前曲目时广播 + * @param trackId - 目标 Track.id + * @param offsetMs - 偏移值(毫秒),自动 clamp 到 ±60s + */ +export const setLyricOffset = (trackId: string, offsetMs: number): void => { + if (!trackId) return; + // IPC 进来可能是 NaN/Infinity,会污染所有下游时间叠加计算,统一视为清除 + const normalized = Number.isFinite(offsetMs) ? Math.trunc(offsetMs) : 0; + const value = Math.max(-LYRIC_OFFSET_LIMIT_MS, Math.min(LYRIC_OFFSET_LIMIT_MS, normalized)); + const map = { ...(store.get("player.lyricOffsets") ?? {}) }; + if (value === 0) delete map[trackId]; + else map[trackId] = value; + store.set("player.lyricOffsets", map); + // 影响当前曲目则更新运行时缓存并广播 + if (currentTrack && currentTrack.id === trackId) { + currentLyricOffsetMs = value; + emitter.emit("lyric-offset-change", { trackId, offsetMs: value }); + } +}; + /** 窗口启动对齐:拉取当前完整状态 */ export const snapshot = (): NowPlayingSnapshot => ({ track: currentTrack, @@ -64,6 +112,7 @@ export const snapshot = (): NowPlayingSnapshot => ({ source: currentSource, position: lastPosition, playing, + lyricOffsetMs: currentLyricOffsetMs, sendTimestamp: Date.now(), }); @@ -72,7 +121,9 @@ export const clear = (): void => { currentTrack = null; currentLyric = []; currentSource = null; + currentLyricOffsetMs = 0; emitter.emit("lyric-change", snapshot()); + emitter.emit("lyric-offset-change", { trackId: null, offsetMs: 0 }); }; /** 订阅歌曲切换 */ @@ -89,3 +140,8 @@ export const onLyricChange = (listener: (snap: NowPlayingSnapshot) => void): voi export const onPositionSync = (listener: (data: NowPlayingPositionSync) => void): void => { emitter.on("position-sync", listener); }; + +/** 订阅当前曲目歌词偏移变化 */ +export const onLyricOffsetChange = (listener: (data: NowPlayingLyricOffsetSync) => void): void => { + emitter.on("lyric-offset-change", listener); +}; diff --git a/electron/main/services/scanner.ts b/electron/main/services/scanner.ts index 4a9be8f4..3a1ea562 100644 --- a/electron/main/services/scanner.ts +++ b/electron/main/services/scanner.ts @@ -11,7 +11,7 @@ import { broadcast } from "@main/utils/broadcast"; import { toCacheUrl } from "@main/utils/protocol"; import { toMs } from "@main/utils/time"; import { parseArtists, parseAlbum } from "@main/utils/metadata"; -import { coverCacheDir } from "@main/utils/config"; +import { getCoverCacheDir } from "@main/utils/config"; import { libraryLog } from "@main/utils/logger"; let scanning = false; @@ -106,7 +106,7 @@ export const startScan = (dirs: string[], incremental = true): void => { } } }, - coverCacheDir, + getCoverCacheDir(), incrementalData, ); }; diff --git a/electron/main/services/songCache.ts b/electron/main/services/songCache.ts new file mode 100644 index 00000000..c71e0a16 --- /dev/null +++ b/electron/main/services/songCache.ts @@ -0,0 +1,336 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import crypto from "node:crypto"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { app } from "electron"; +import { store } from "@main/store"; +import { getSongCacheDir } from "@main/utils/config"; +import { songCacheLog } from "@main/utils/logger"; +import type { TrackSource } from "@shared/types/player"; +import { + clearAll as dbClearAll, + deleteByKey, + findByFilename, + findByKey, + listAllFilenames, + listLruVictims, + totalSize, + touchLastUsed, + upsert, +} from "@main/database/songCache"; + +/** 下载并发上限 */ +const MAX_CONCURRENT = 2; +/** 一批 LRU 淘汰数 */ +const EVICT_BATCH = 8; + +interface InFlight { + promise: Promise; + controller: AbortController; +} + +/** 当前生效的歌曲缓存目录 */ +let cacheDir = getSongCacheDir(); +/** 进行中的下载,按 cacheKey 去重 */ +const inFlight = new Map(); +/** 等待槽位的队列;只存 starter,槽位空出来时取队头执行 */ +const waiting: Array<() => void> = []; + +/** sizeLimit 字节数;0/负数视为不限制 */ +const sizeLimitBytes = (): number => { + const gb = store.get("cache.songCache.sizeLimitGb") ?? 10; + return gb > 0 ? gb * 1024 * 1024 * 1024 : Number.POSITIVE_INFINITY; +}; + +/** 是否启用歌曲缓存 */ +const isCacheEnabled = (): boolean => store.get("cache.songCache.enabled") === true; + +/** + * 用 cache_key 派生唯一文件名 + * @param cacheKey - 缓存键 + * @returns 文件名 + */ +const filenameFor = (cacheKey: string): string => { + const hash = crypto.createHash("sha1").update(cacheKey).digest("hex").slice(0, 16); + return `${hash}.bin`; +}; + +/** 获取文件的绝对路径 + * @param filename - 文件名 + * @returns 绝对路径 + */ +const absPath = (filename: string): string => path.join(cacheDir, filename); + +/** + * 占一个并发槽位 + * @returns + */ +const acquireSlot = async (): Promise => { + if (inFlight.size < MAX_CONCURRENT) return; + await new Promise((resolve) => waiting.push(resolve)); +}; + +/** + * 释放槽位并唤醒一个等待者 + * @returns + */ +const releaseSlot = (): void => { + const next = waiting.shift(); + if (next) next(); +}; + +/** + * 启动时孤儿清理 + * 删 .part;删表里有文件不在的;删目录里有表里没的 + */ +const cleanupOrphans = async (): Promise => { + let entries: string[]; + try { + entries = await fsp.readdir(cacheDir); + } catch { + return; + } + + let partRemoved = 0; + let orphanFiles = 0; + for (const name of entries) { + if (name.endsWith(".part")) { + try { + await fsp.unlink(path.join(cacheDir, name)); + partRemoved += 1; + } catch {} + } + } + + const known = new Set(listAllFilenames()); + for (const name of entries) { + if (name.endsWith(".part")) continue; + if (!known.has(name)) { + try { + await fsp.unlink(path.join(cacheDir, name)); + orphanFiles += 1; + } catch {} + } + } + + let missingRows = 0; + for (const filename of known) { + if (!fs.existsSync(path.join(cacheDir, filename))) { + const row = findByFilename(filename); + if (row) deleteByKey(row.cacheKey); + missingRows += 1; + } + } + + songCacheLog.info( + `[init] dir=${cacheDir} orphans.part=${partRemoved} orphan-files=${orphanFiles} missing-rows=${missingRows}`, + ); +}; + +/** + * LRU 淘汰:超过 cap 时按 last_used_at 升序批量删 + * - 删超过 cap 的 + * - 删表里有文件不在的 + * - 删目录里有表里没的 + */ +const evictIfNeeded = async (): Promise => { + const cap = sizeLimitBytes(); + let current = totalSize(); + if (current <= cap) return; + + let evicted = 0; + let freed = 0; + while (current > cap) { + const victims = listLruVictims(EVICT_BATCH); + if (victims.length === 0) break; + for (const victim of victims) { + try { + await fsp.unlink(absPath(victim.filename)); + } catch {} + deleteByKey(victim.cacheKey); + current -= victim.size; + freed += victim.size; + evicted += 1; + } + } + if (evicted > 0) songCacheLog.info(`[evict] count=${evicted} freed=${freed}`); +}; + +/** + * 实际下载实现 + * @param cacheKey - 缓存键 + * @param source - 来源 + * @param streamUrl - 流 URL + * @param controller - 控制器 + * @returns 文件路径 + */ +const runDownload = async ( + cacheKey: string, + source: TrackSource, + streamUrl: string, + controller: AbortController, +): Promise => { + const start = Date.now(); + const filename = filenameFor(cacheKey); + const finalPath = absPath(filename); + const partPath = `${finalPath}.part`; + + try { + await fsp.mkdir(cacheDir, { recursive: true }); + const response = await fetch(streamUrl, { signal: controller.signal }); + if (!response.ok || !response.body) { + songCacheLog.warn(`[fetch] fail key=${cacheKey} status=${response.status}`); + return null; + } + + const mime = response.headers.get("content-type"); + const contentLengthHeader = response.headers.get("content-length"); + const declaredSize = contentLengthHeader ? Number(contentLengthHeader) : NaN; + if (Number.isFinite(declaredSize) && declaredSize > sizeLimitBytes()) { + songCacheLog.warn(`[fetch] skip oversize key=${cacheKey} declared=${declaredSize}`); + return null; + } + + const nodeStream = Readable.fromWeb(response.body as never); + const writeStream = fs.createWriteStream(partPath); + await pipeline(nodeStream, writeStream); + + const stat = await fsp.stat(partPath); + if (stat.size === 0) { + await fsp.unlink(partPath).catch(() => {}); + songCacheLog.warn(`[fetch] empty key=${cacheKey}`); + return null; + } + if (stat.size > sizeLimitBytes()) { + await fsp.unlink(partPath).catch(() => {}); + songCacheLog.warn(`[fetch] post oversize key=${cacheKey} actual=${stat.size}`); + return null; + } + + await fsp.rename(partPath, finalPath); + const now = Date.now(); + upsert({ + cacheKey, + source, + filename, + size: stat.size, + mime, + cachedAt: now, + lastUsedAt: now, + }); + await evictIfNeeded(); + + songCacheLog.info(`[fetch] done key=${cacheKey} size=${stat.size} ms=${Date.now() - start}`); + return finalPath; + } catch (err) { + await fsp.unlink(partPath).catch(() => {}); + if (controller.signal.aborted) { + songCacheLog.info(`[fetch] cancel key=${cacheKey}`); + } else { + songCacheLog.error(`[fetch] error key=${cacheKey}`, err); + } + return null; + } +}; + +/** 启动初始化:建目录 + 孤儿清理 */ +export const init = async (): Promise => { + cacheDir = getSongCacheDir(); + await fsp.mkdir(cacheDir, { recursive: true }); + await cleanupOrphans(); + app.on("before-quit", () => { + for (const entry of inFlight.values()) entry.controller.abort(); + }); +}; + +/** 切换缓存目录后调用,让服务感知新前缀 */ +export const reloadDir = (): void => { + cacheDir = getSongCacheDir(); +}; + +/** 查询命中;命中则更新 last_used_at 并返回本地绝对路径 */ +export const lookup = async (cacheKey: string): Promise => { + if (!isCacheEnabled()) return null; + const row = findByKey(cacheKey); + if (!row) return null; + const full = absPath(row.filename); + if (!fs.existsSync(full)) { + deleteByKey(cacheKey); + return null; + } + touchLastUsed(cacheKey, Date.now()); + return full; +}; + +/** + * 异步排队下载 + * @param cacheKey - 缓存键 + * @param source - 来源 + * @param streamUrl - 流 URL + * @returns 文件路径 + */ +export const fetchAsync = ( + cacheKey: string, + source: TrackSource, + streamUrl: string, +): Promise => { + if (!isCacheEnabled()) return Promise.resolve(null); + const existing = inFlight.get(cacheKey); + if (existing) return existing.promise; + + const controller = new AbortController(); + const promise = (async () => { + await acquireSlot(); + try { + return await runDownload(cacheKey, source, streamUrl, controller); + } finally { + inFlight.delete(cacheKey); + releaseSlot(); + } + })(); + inFlight.set(cacheKey, { promise, controller }); + return promise; +}; + +/** + * 取消正在进行的下载 + * @param cacheKey - 缓存键 + */ +export const cancel = (cacheKey: string): void => { + const entry = inFlight.get(cacheKey); + if (entry) entry.controller.abort(); +}; + +/** + * 失效:删文件 + 删表 + * 如果表里没有或文件不存在则静默返回 + * @param sourcePath - 传入原始来源路径(streamUrl 或本地路径),用来找到对应的 cacheKey 和文件 + */ +export const invalidate = async (sourcePath: string): Promise => { + const filename = path.basename(sourcePath); + const row = findByFilename(filename); + if (!row) return; + deleteByKey(row.cacheKey); + await fsp.unlink(absPath(filename)).catch(() => {}); + songCacheLog.info(`[invalidate] path=${sourcePath}`); +}; + +/** 清空全部 */ +export const clearAll = async (): Promise => { + for (const entry of inFlight.values()) entry.controller.abort(); + inFlight.clear(); + dbClearAll(); + try { + const entries = await fsp.readdir(cacheDir); + await Promise.all(entries.map((name) => fsp.unlink(path.join(cacheDir, name)).catch(() => {}))); + } catch {} + songCacheLog.info("[clearAll] done"); +}; + +/** 占用统计 */ +export const stats = (): { size: number; path: string } => ({ + size: totalSize(), + path: cacheDir, +}); diff --git a/electron/main/store/index.ts b/electron/main/store/index.ts index 68ed733d..476b9776 100644 --- a/electron/main/store/index.ts +++ b/electron/main/store/index.ts @@ -123,4 +123,11 @@ export const store = { data = structuredClone(defaultSystemConfig); flush(data); }, + + /** 用导入的配置替换当前配置 */ + replaceAll(input: unknown): void { + const raw = (input && typeof input === "object" ? input : {}) as Record; + data = deepMerge(defaultSystemConfig, raw); + flushImmediate(data); + }, }; diff --git a/electron/main/utils/config.ts b/electron/main/utils/config.ts index 1e45848d..8a513346 100644 --- a/electron/main/utils/config.ts +++ b/electron/main/utils/config.ts @@ -1,6 +1,7 @@ import { is } from "@electron-toolkit/utils"; import { app } from "electron"; import path from "node:path"; +import { store } from "@main/store"; /** * 是否为开发环境 @@ -24,11 +25,20 @@ export const appVersion = app.getVersion(); /** 应用名称 */ export const appName = app.getName(); -/** 应用自有缓存根目录(区别于 Electron 内置的 Cache/Code Cache) */ -export const appCacheDir = path.join(app.getPath("userData"), "app-cache"); +/** 默认缓存根目录(区别于 Electron 内置的 Cache/Code Cache) */ +export const defaultAppCacheDir = path.join(app.getPath("userData"), "app-cache"); -/** 封面缩略图缓存目录 */ -export const coverCacheDir = path.join(appCacheDir, "covers"); +/** 当前生效的缓存根目录:用户在设置中可选自定义路径,未配置时回退默认 */ +export const getAppCacheDir = (): string => store.get("cache.dir") || defaultAppCacheDir; -/** 歌手头像缓存目录 */ -export const artistCacheDir = path.join(appCacheDir, "artists"); +/** 当前生效的封面缩略图目录 */ +export const getCoverCacheDir = (): string => path.join(getAppCacheDir(), "covers"); + +/** 当前生效的歌手头像目录 */ +export const getArtistCacheDir = (): string => path.join(getAppCacheDir(), "artists"); + +/** 当前生效的背景图目录 */ +export const getBackgroundsDir = (): string => path.join(getAppCacheDir(), "backgrounds"); + +/** 当前生效的歌曲缓存目录 */ +export const getSongCacheDir = (): string => path.join(getAppCacheDir(), "songs"); diff --git a/electron/main/utils/logger.ts b/electron/main/utils/logger.ts index 0573e934..6a1953cc 100644 --- a/electron/main/utils/logger.ts +++ b/electron/main/utils/logger.ts @@ -84,3 +84,4 @@ export const libraryLog = log.scope("library"); export const taskbarLog = log.scope("taskbar-lyric"); export const nativeLog = log.scope("native"); export const streamingLog = log.scope("streaming"); +export const songCacheLog = log.scope("songCache"); diff --git a/electron/main/utils/protocol.ts b/electron/main/utils/protocol.ts index 795459f1..85d88567 100644 --- a/electron/main/utils/protocol.ts +++ b/electron/main/utils/protocol.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { net, protocol, session } from "electron"; -import { appCacheDir } from "./config"; +import { getAppCacheDir } from "./config"; /** cache:// 协议方案名 */ const SCHEME = "cache"; @@ -26,8 +26,14 @@ export const registerCacheScheme = (): void => { /** cache:// 协议的处理函数 */ const cacheHandler = (request: Request): Response | Promise => { const relativePath = decodeURIComponent(request.url.slice(`${SCHEME}://`.length)); - const filePath = path.join(appCacheDir, relativePath); - return net.fetch(`file://${filePath.replace(/\\/g, "/")}`); + const root = getAppCacheDir(); + const resolved = path.resolve(root, relativePath); + // 防 cache://../ 逃逸:解析后的绝对路径必须仍在缓存根目录内 + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + if (resolved !== root && !resolved.startsWith(rootWithSep)) { + return new Response(null, { status: 403 }); + } + return net.fetch(`file://${resolved.replace(/\\/g, "/")}`); }; /** @@ -53,6 +59,8 @@ export const handleCacheProtocolOnPartition = (partition: string): void => { */ export const toCacheUrl = (filePath: string | undefined | null): string | undefined => { if (!filePath) return undefined; - const relative = path.relative(appCacheDir, filePath).replace(/\\/g, "/"); - return `${SCHEME}://${relative}`; + const relative = path.relative(getAppCacheDir(), filePath); + // 路径不在缓存根目录下时拒绝生成 URL,避免产出可逃逸的 cache:// 链接 + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return undefined; + return `${SCHEME}://${relative.replace(/\\/g, "/")}`; }; diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 3a460c19..1e05a000 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -1,5 +1,5 @@ import { ElectronAPI } from "@electron-toolkit/preload"; -import { PlayerApi } from "@shared/types/player"; +import { PlayerApi, TrackSource } from "@shared/types/player"; import { ConfigApi, LocaleCode } from "@shared/types/settings"; import { LibraryApi } from "@shared/types/library"; import { NowPlayingApi } from "@shared/types/nowPlaying"; @@ -33,6 +33,7 @@ declare global { ) => () => void; listFonts: () => Promise; fetchRemoteBytes: (url: string) => Promise>; + relaunch: () => Promise; }; library: LibraryApi; window: WindowApi; @@ -47,6 +48,23 @@ declare global { pickBackgroundImage: () => Promise; clearBackgroundImages: () => Promise; }; + cache: { + getStats: () => Promise<{ id: string; kind: "file" | "db"; path: string; size: number }[]>; + clear: (id: string) => Promise; + clearAllByKind: (kind: "file" | "db") => Promise; + getDir: () => Promise; + pickDir: () => Promise<{ ok: boolean; dir: string; reason?: "canceled" | "notEmpty" }>; + resetDir: () => Promise; + song: { + lookup: (cacheKey: string) => Promise; + fetch: ( + cacheKey: string, + source: TrackSource, + streamUrl: string, + ) => Promise; + cancel: (cacheKey: string) => Promise; + }; + }; hotkey: HotkeyApi; streaming: StreamingApi; }; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index ea42e78e..6ea978c8 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -3,7 +3,7 @@ import { electronAPI } from "@electron-toolkit/preload"; import type { TaskbarLyricSettings } from "@shared/types/settings"; import type { PluginInfo, PluginResolveUrlArgs } from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; -import type { LoadOptions } from "@shared/types/player"; +import type { LoadOptions, TrackSource } from "@shared/types/player"; import type { StreamingServerConfig } from "@shared/types/streaming"; /** 订阅主进程推送的事件 */ @@ -20,6 +20,14 @@ const api = { set: (keyPath: string, value: unknown) => ipcRenderer.invoke("config:set", keyPath, value), getAll: () => ipcRenderer.invoke("config:getAll"), reset: () => ipcRenderer.invoke("config:reset"), + replaceAll: (config: unknown) => ipcRenderer.invoke("config:replaceAll", config), + exportToFile: ( + payload: unknown, + ): Promise<{ ok: boolean; reason?: "canceled" | "writeFailed" }> => + ipcRenderer.invoke("config:exportToFile", payload), + importFromFile: (): Promise< + { ok: true; data: unknown } | { ok: false; reason: "canceled" | "readFailed" | "parseFailed" } + > => ipcRenderer.invoke("config:importFromFile"), }, player: { // 加载音频(本地路径或网络地址) @@ -106,6 +114,8 @@ const api = { listFonts: () => ipcRenderer.invoke("system:listFonts"), // 拉远端字节回渲染层 fetchRemoteBytes: (url: string) => ipcRenderer.invoke("system:fetchRemoteBytes", url), + // 重启应用 + relaunch: () => ipcRenderer.invoke("system:relaunch"), }, library: { // 开始扫描(默认增量) @@ -290,6 +300,9 @@ const api = { update: (payload: unknown) => ipcRenderer.send("nowPlaying:update", payload), // 拉取当前完整快照 requestSnapshot: () => ipcRenderer.invoke("nowPlaying:requestSnapshot"), + // 写入指定曲目的歌词偏移(ms),0 视为清除 + setLyricOffset: (trackId: string, offsetMs: number) => + ipcRenderer.send("nowPlaying:setLyricOffset", trackId, offsetMs), // 订阅歌曲切换事件 onTrackChange: (callback: (data: unknown) => void) => subscribe("nowPlaying:track-change", callback), @@ -299,6 +312,9 @@ const api = { // 订阅播放位置锚点(跟随 position 事件 5Hz) onPositionSync: (callback: (data: unknown) => void) => subscribe("nowPlaying:position-sync", callback), + // 订阅当前曲目歌词偏移变化 + onLyricOffsetChange: (callback: (data: unknown) => void) => + subscribe("nowPlaying:lyric-offset-change", callback), }, theme: { // 弹出文件选择框 @@ -307,6 +323,32 @@ const api = { // 清空已缓存的背景图 clearBackgroundImages: (): Promise => ipcRenderer.invoke("theme:clearBackgroundImages"), }, + cache: { + // 各类别占用统计 + getStats: () => ipcRenderer.invoke("cache:getStats"), + // 清除单个类别 + clear: (id: string) => ipcRenderer.invoke("cache:clear", id), + // 按介质清空 + clearAllByKind: (kind: "file" | "db") => ipcRenderer.invoke("cache:clearAllByKind", kind), + // 获取当前缓存目录 + getDir: () => ipcRenderer.invoke("cache:getDir"), + // 选择新的缓存目录 + pickDir: () => ipcRenderer.invoke("cache:pickDir"), + // 还原默认缓存目录 + resetDir: () => ipcRenderer.invoke("cache:resetDir"), + // 单曲文件缓存运行时 + song: { + // 命中查询:返回本地绝对路径或 null + lookup: (cacheKey: string): Promise => + ipcRenderer.invoke("cache:song:lookup", cacheKey), + // 排队下载(fire-and-forget 也可 await) + fetch: (cacheKey: string, source: TrackSource, streamUrl: string): Promise => + ipcRenderer.invoke("cache:song:fetch", cacheKey, source, streamUrl), + // 取消正在进行的下载 + cancel: (cacheKey: string): Promise => + ipcRenderer.invoke("cache:song:cancel", cacheKey), + }, + }, streaming: { // 加载服务器配置(密码已解密) loadServers: () => ipcRenderer.invoke("streaming:loadServers"), diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index 52de4116..589edf55 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -26,6 +26,7 @@ export const defaultSystemConfig: SystemConfig = { bands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], preamp: 0, }, + lyricOffsets: {}, }, media: { systemMediaControls: true, @@ -88,6 +89,16 @@ export const defaultSystemConfig: SystemConfig = { enableOnlineTTMLLyric: false, amllDbServer: "https://amlldb.bikonoo.com/%p/%s.ttml", }, + localLyric: { + enableLocalTTMLOverride: false, + }, + cache: { + dir: null, + songCache: { + enabled: false, + sizeLimitGb: 10, + }, + }, streaming: { enabled: true, }, diff --git a/shared/types/nowPlaying.ts b/shared/types/nowPlaying.ts index 49f95069..e6230e1b 100644 --- a/shared/types/nowPlaying.ts +++ b/shared/types/nowPlaying.ts @@ -15,6 +15,8 @@ export interface NowPlayingSnapshot { source: LyricData; position: number; playing: boolean; + /** 当前曲目的歌词偏移(ms,正值为歌词提前) */ + lyricOffsetMs: number; /** 发送时刻的主进程时钟(Date.now 毫秒),接收端用于补偿 IPC 延迟 */ sendTimestamp: number; } @@ -26,16 +28,28 @@ export interface NowPlayingPositionSync { sendTimestamp: number; } +/** 主进程 → 渲染端 / 窗口:当前曲目歌词偏移变化(切歌或用户调整) */ +export interface NowPlayingLyricOffsetSync { + /** 对应的 Track.id;null 表示当前无曲目 */ + trackId: string | null; + /** 当前偏移(ms) */ + offsetMs: number; +} + /** NowPlaying API */ export interface NowPlayingApi { /** 同步当前播放状态到主进程 */ update: (payload: NowPlayingUpdatePayload) => void; /** 拉取当前完整快照 */ requestSnapshot: () => Promise; + /** 写入指定曲目的歌词偏移(ms);0 视为清除 */ + setLyricOffset: (trackId: string, offsetMs: number) => void; /** 订阅歌曲切换 */ onTrackChange: (callback: (data: { track: Track | null }) => void) => () => void; /** 订阅歌词内容变化 */ onLyricChange: (callback: (snapshot: NowPlayingSnapshot) => void) => () => void; /** 订阅播放位置锚点 */ onPositionSync: (callback: (data: NowPlayingPositionSync) => void) => () => void; + /** 订阅当前曲目歌词偏移变化 */ + onLyricOffsetChange: (callback: (data: NowPlayingLyricOffsetSync) => void) => () => void; } diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 8a670457..7f4b2712 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -47,6 +47,8 @@ export interface PlayerSettings { loudnessNormalization: boolean; /** 均衡器配置 */ equalizer: EqualizerSettings; + /** 按 Track.id 记忆的歌词偏移(ms,正值为歌词提前);为 0 时不写入 */ + lyricOffsets: Record; } /** Discord 显示模式 */ @@ -191,6 +193,28 @@ export interface OnlineLyricSettings { amllDbServer: string; } +/** 本地歌词配置 */ +export interface LocalLyricSettings { + /** 启用本地 TTML 覆盖:扫描到同目录的 .ttml 时优先于在线源 */ + enableLocalTTMLOverride: boolean; +} + +/** 歌曲缓存配置 */ +export interface SongCacheSettings { + /** 开关:开启后播放远程歌曲会异步下载落盘,下次播放命中本地 */ + enabled: boolean; + /** 上限(GB),0 表示不限制;超限按 LRU 淘汰 */ + sizeLimitGb: number; +} + +/** 缓存配置 */ +export interface CacheSettings { + /** 自定义缓存目录;null 使用默认 {userData}/app-cache */ + dir: string | null; + /** 歌曲文件级缓存 */ + songCache: SongCacheSettings; +} + /** 主窗口几何 */ export interface MainWindowState { width: number; @@ -249,6 +273,10 @@ export interface SystemConfig { taskbarLyric: TaskbarLyricSettings; /** 在线歌词服务配置 */ lyric: OnlineLyricSettings; + /** 本地歌词配置 */ + localLyric: LocalLyricSettings; + /** 缓存配置 */ + cache: CacheSettings; /** 流媒体总开关 */ streaming: StreamingSettings; /** 系统配置 */ @@ -276,4 +304,12 @@ export interface ConfigApi { getAll: () => Promise; /** 重置为默认值 */ reset: () => Promise; + /** 整盘替换主进程配置 */ + replaceAll: (config: unknown) => Promise; + /** 写入用户选择的备份文件 */ + exportToFile: (payload: unknown) => Promise<{ ok: boolean; reason?: "canceled" | "writeFailed" }>; + /** 读取用户选择的备份文件 */ + importFromFile: () => Promise< + { ok: true; data: unknown } | { ok: false; reason: "canceled" | "readFailed" | "parseFailed" } + >; } diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index 710f8789..100ab388 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -231,136 +231,139 @@ defineExpose({ diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index a63a94ac..6c6a8faa 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -5,22 +5,25 @@ import { useSettingsStore } from "@/stores/settings"; import { usePlaybackTime } from "@/composables/usePlaybackTime"; import Lyrics from "@/components/player/Lyrics/index.vue"; import { useWindowControls } from "@/composables/useWindowControls"; +import { useSettingsDialog } from "@/settings/useSettingsDialog"; import * as player from "@/core/player"; -import { formatTime } from "@/utils/time"; +import { formatTime, formatSignedSec } from "@/utils/time"; +const { t } = useI18n(); const status = useStatusStore(); const media = useMediaStore(); const settings = useSettingsStore(); +const settingsDialog = useSettingsDialog(); const { isPlaying, isLoading, position, duration, isExpanded, repeatMode, shuffleMode, showLyric } = storeToRefs(status); /** 歌词组件引用 */ const lyricRef = ref>(); -/** 精确播放时间(毫秒) */ +/** 精确播放时间(毫秒);offset 直接读 status mirror(主进程权威源) */ const { start: startTick, stop: stopTick } = usePlaybackTime((currentMs) => { if (!status.trackLoading && !media.lyricLoading) { - lyricRef.value?.setCurrentTime(currentMs); + lyricRef.value?.setCurrentTime(currentMs + status.lyricOffsetMs); } }); @@ -138,6 +141,42 @@ watch(immersiveEnabled, (on) => { }); onBeforeUnmount(() => clearTimeout(idleTimer)); + +/** 歌词偏移步长(ms) */ +const LYRIC_OFFSET_STEP = 500; + +/** 偏移弹层是否打开;打开期间按钮组保持可见 */ +const offsetPopoverOpen = ref(false); + +/** 歌词区右侧操作按钮组的显隐 */ +const lyricActionsClass = computed(() => { + if (immersive.value) return "opacity-0 pointer-events-none"; + if (offsetPopoverOpen.value) return "opacity-100 pointer-events-auto"; + return "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"; +}); + +/** 当前曲目偏移(读 mirror,写走主进程 IPC) */ +const songOffset = computed(() => status.lyricOffsetMs); + +/** 写入偏移;trackId 不存在时静默忽略 */ +const writeOffset = (offsetMs: number): void => { + const id = media.track?.id; + if (!id) return; + window.api.nowPlaying.setLyricOffset(id, offsetMs); +}; + +/** 弹层里直接编辑(ms) */ +const offsetInputMs = computed({ + get: () => songOffset.value, + set: (val) => writeOffset(val ?? 0), +}); + +/** 歌词提前 */ +const advanceLyric = (): void => writeOffset(songOffset.value + LYRIC_OFFSET_STEP); +/** 歌词延后 */ +const delayLyric = (): void => writeOffset(songOffset.value - LYRIC_OFFSET_STEP); +/** 重置歌词偏移 */ +const resetLyricOffset = (): void => writeOffset(0);