From 657c1794d81f1376cb73637c214472d89bcba69b Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 6 Jul 2026 10:59:09 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20CUE=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/database/index.ts | 5 + electron/main/database/migration.ts | 19 ++- electron/main/database/queries.ts | 46 +++++- electron/main/ipc/player.ts | 65 ++++++-- electron/main/services/cue.test.ts | 42 +++++ electron/main/services/cue.ts | 191 ++++++++++++++++++++++ electron/main/services/scanner.ts | 126 ++++++++++++-- shared/types/player.ts | 8 + src/components/modals/TagEditorDialog.vue | 6 +- src/composables/useTrackMenu.ts | 12 +- src/core/player/events.ts | 50 +++--- src/services/audioSource.ts | 3 +- 12 files changed, 514 insertions(+), 59 deletions(-) create mode 100644 electron/main/services/cue.test.ts create mode 100644 electron/main/services/cue.ts diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index 33e3ddb8..3ce5ae18 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -35,6 +35,10 @@ export const initDatabase = (): void => { CREATE TABLE IF NOT EXISTS tracks ( id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, + cue_path TEXT, + cue_audio_path TEXT, + cue_start_ms INTEGER, + cue_end_ms INTEGER, title TEXT NOT NULL, track INTEGER, artists TEXT NOT NULL DEFAULT '[]', @@ -150,6 +154,7 @@ export { getAllTracks, getTrackCount, getFileRecords, + getCueTrackPathsByDirs, upsertTracks, deleteTracksByPaths, searchTracks, diff --git a/electron/main/database/migration.ts b/electron/main/database/migration.ts index ea8524b7..728d2621 100644 --- a/electron/main/database/migration.ts +++ b/electron/main/database/migration.ts @@ -1,7 +1,7 @@ import type Database from "better-sqlite3"; /** 当前 schema 版本 */ -const SCHEMA_VERSION = 3; +const SCHEMA_VERSION = 4; type TableInfoRow = { name: string }; @@ -35,6 +35,23 @@ export const migrate = (d: Database.Database): void => { v = 3; } + // v3 → v4: 添加 CUE 分轨列 + if (v < 4) { + if (!hasColumn(d, "tracks", "cue_path")) { + d.exec("ALTER TABLE tracks ADD COLUMN cue_path TEXT"); + } + if (!hasColumn(d, "tracks", "cue_audio_path")) { + d.exec("ALTER TABLE tracks ADD COLUMN cue_audio_path TEXT"); + } + if (!hasColumn(d, "tracks", "cue_start_ms")) { + d.exec("ALTER TABLE tracks ADD COLUMN cue_start_ms INTEGER"); + } + if (!hasColumn(d, "tracks", "cue_end_ms")) { + d.exec("ALTER TABLE tracks ADD COLUMN cue_end_ms INTEGER"); + } + v = 4; + } + // 版本无关部分 // 补 lyric_match_cache.extra 列 if (!hasColumn(d, "lyric_match_cache", "extra")) { diff --git a/electron/main/database/queries.ts b/electron/main/database/queries.ts index d1ab7e05..72249cf2 100644 --- a/electron/main/database/queries.ts +++ b/electron/main/database/queries.ts @@ -7,6 +7,10 @@ import { getDb } from "./index"; interface TrackRow { id: string; path: string; + cue_path: string | null; + cue_audio_path: string | null; + cue_start_ms: number | null; + cue_end_ms: number | null; title: string; track?: number; artists: string; @@ -41,6 +45,10 @@ const rowToTrack = (row: TrackRow): Track => { id: row.id, source: "local", path: row.path, + cuePath: row.cue_path ?? undefined, + cueAudioPath: row.cue_audio_path ?? undefined, + cueStartMs: row.cue_start_ms ?? undefined, + cueEndMs: row.cue_end_ms ?? undefined, title: row.title, track: row.track ?? undefined, artists: JSON.parse(row.artists) as Artist[], @@ -91,10 +99,12 @@ export interface FileRecord { size: number; } -/** 获取所有文件记录(path + mtime + size),用于增量扫描比对 */ +/** 获取所有真实音频文件记录(path + mtime + size),用于增量扫描比对 */ export const getFileRecords = (): FileRecord[] => { return getDb() - .prepare("SELECT path, COALESCE(file_mtime, 0) as mtime, file_size as size FROM tracks") + .prepare( + "SELECT path, COALESCE(file_mtime, 0) as mtime, file_size as size FROM tracks WHERE cue_path IS NULL", + ) .all() as FileRecord[]; }; @@ -102,6 +112,10 @@ export const getFileRecords = (): FileRecord[] => { export interface UpsertTrack { id: string; path: string; + cuePath?: string; + cueAudioPath?: string; + cueStartMs?: number; + cueEndMs?: number; title: string; track?: number; artists: Artist[]; @@ -124,9 +138,9 @@ export const upsertTracks = (tracks: UpsertTrack[]): void => { const d = getDb(); const stmt = d.prepare(` INSERT OR REPLACE INTO tracks - (id, path, title, track, artists, album, duration, cover, codec, sample_rate, bit_rate, channels, bits_per_sample, file_size, file_mtime, file_ctime, scanned_at) + (id, path, cue_path, cue_audio_path, cue_start_ms, cue_end_ms, title, track, artists, album, duration, cover, codec, sample_rate, bit_rate, channels, bits_per_sample, file_size, file_mtime, file_ctime, scanned_at) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const now = Date.now(); const tx = d.transaction(() => { @@ -134,6 +148,10 @@ export const upsertTracks = (tracks: UpsertTrack[]): void => { stmt.run( t.id, t.path, + t.cuePath ?? null, + t.cueAudioPath ?? null, + t.cueStartMs ?? null, + t.cueEndMs ?? null, t.title, t.track ?? null, JSON.stringify(t.artists), @@ -155,14 +173,26 @@ export const upsertTracks = (tracks: UpsertTrack[]): void => { tx(); }; +/** 查询指定目录下的 CUE 虚拟曲目路径 */ +export const getCueTrackPathsByDirs = (dirs: string[]): string[] => { + if (dirs.length === 0) return []; + const rows: { path: string }[] = []; + const stmt = getDb().prepare("SELECT path FROM tracks WHERE cue_path LIKE ?"); + for (const dir of dirs) { + const prefix = dir.endsWith("/") || dir.endsWith("\\") ? dir : dir + path.sep; + rows.push(...(stmt.all(prefix + "%") as { path: string }[])); + } + return rows.map((row) => row.path); +}; + /** 批量删除曲目(按路径) */ export const deleteTracksByPaths = (paths: string[]): void => { if (paths.length === 0) return; const d = getDb(); - const stmt = d.prepare("DELETE FROM tracks WHERE path = ?"); + const stmt = d.prepare("DELETE FROM tracks WHERE path = ? OR cue_audio_path = ? OR cue_path = ?"); const tx = d.transaction(() => { for (const p of paths) { - stmt.run(p); + stmt.run(p, p, p); } }); tx(); @@ -184,8 +214,8 @@ export const searchTracks = (query: string): Track[] => { export const deleteTracksByDir = (dir: string): void => { const prefix = dir.endsWith("/") || dir.endsWith("\\") ? dir : dir + path.sep; getDb() - .prepare("DELETE FROM tracks WHERE path LIKE ?") - .run(prefix + "%"); + .prepare("DELETE FROM tracks WHERE path LIKE ? OR cue_path LIKE ? OR cue_audio_path LIKE ?") + .run(prefix + "%", prefix + "%", prefix + "%"); }; /** 专辑列表 */ diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 1b3c1280..b2e4a973 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -33,6 +33,36 @@ import { JsPlayerEvent } from "@splayer/audio-engine"; type AudioEngineModule = typeof import("@splayer/audio-engine"); +interface CueRange { + startMs: number; + durationMs: number; +} + +/** 当前加载的 CUE 分轨范围 */ +let activeCueRange: CueRange | null = null; + +/** 从 Track 元数据提取 CUE 分轨范围 */ +const cueRangeFromTrack = (track: LoadOptions["meta"] | null | undefined): CueRange | null => { + const start = track?.cueStartMs; + const end = track?.cueEndMs; + if (start == null || end == null || end <= start) return null; + return { startMs: start, durationMs: end - start }; +}; + +/** 引擎绝对时间转换为当前曲目展示时间 */ +const toDisplayPositionMs = (positionMs: number): number => { + if (!activeCueRange) return positionMs; + return Math.max(0, Math.min(activeCueRange.durationMs, positionMs - activeCueRange.startMs)); +}; + +/** 当前曲目展示时长 */ +const toDisplayDurationMs = (durationMs: number): number => + activeCueRange?.durationMs ?? durationMs; + +/** 展示时间转换为引擎绝对时间 */ +const toEnginePositionMs = (positionMs: number): number => + activeCueRange ? activeCueRange.startMs + positionMs : positionMs; + /** 返回失败响应,附带日志 */ const fail = (code: ErrorCode, error?: unknown) => { if (error) playerLog.error(`${code}:`, error); @@ -59,8 +89,9 @@ const registerNativeEvents = (inst: InstanceType 0) setTaskbarProgress(inst.getPosition() / dur, true); + const dur = toDisplayDurationMs(toMs(inst.getDuration())); + const pos = toDisplayPositionMs(toMs(inst.getPosition())); + if (dur > 0) setTaskbarProgress(pos / dur, true); } } else if (state === "stopped") { mediaService.setPlayState({ status: "Paused" }); @@ -73,8 +104,8 @@ const registerNativeEvents = (inst: InstanceType { ipcMain.handle("player:load", async (_event, source: string, options: LoadOptions = {}) => { const autoPlay = options.autoPlay ?? true; const authoritative = options.meta ?? null; + const cueRange = cueRangeFromTrack(authoritative); + activeCueRange = cueRange; // 非本地音源 const isRemote = authoritative != null && authoritative.source !== "local"; const seq = ++loadSeq; @@ -199,8 +232,13 @@ export const registerPlayerIpc = (): void => { authoritative.duration ?? 0, ); } - const meta = await inst.load(source, autoPlay); - const durationMs = toMs(meta.duration); + const meta = await inst.load(source, cueRange ? false : autoPlay); + if (cueRange) { + await inst.seek(cueRange.startMs / 1000); + if (autoPlay) await inst.play(); + } + const nativeDurationMs = toMs(meta.duration); + const durationMs = toDisplayDurationMs(nativeDurationMs); const fallbackTitle = meta.title || source.split(/[/\\]/).pop() || source; const displayTitle = authoritative?.title ?? fallbackTitle; const displayArtist = authoritative @@ -262,6 +300,7 @@ export const registerPlayerIpc = (): void => { playerLog.debug(`加载成功: ${displayTitle}`); return { success: true, data }; } catch (error) { + if (seq === loadSeq) activeCueRange = null; const msg = error instanceof Error ? error.message : String(error); // 被更新的 load/stop 取代是正常竞态结果,不能按源类型误判为网络/解码错误 //(那两类是可跳曲错误,会让用户的停止操作变成自动跳下一曲) @@ -306,6 +345,7 @@ export const registerPlayerIpc = (): void => { // 停止播放并释放资源 ipcMain.handle("player:stop", () => { try { + activeCueRange = null; getPlayer().stop(); return { success: true }; } catch (error) { @@ -316,11 +356,12 @@ export const registerPlayerIpc = (): void => { // 跳转到指定播放位置 ipcMain.handle("player:seek", async (_event, positionMs: number) => { try { - const positionSecs = positionMs / 1000; + const enginePositionMs = toEnginePositionMs(positionMs); + const positionSecs = enginePositionMs / 1000; await getPlayer().seek(positionSecs); mediaService.setTimeline({ currentMs: positionMs, - totalMs: toMs(getPlayer().getDuration()), + totalMs: toDisplayDurationMs(toMs(getPlayer().getDuration())), seeked: true, }); return { success: true }; @@ -367,8 +408,8 @@ export const registerPlayerIpc = (): void => { success: true, data: { state: raw.state, - position: toMs(raw.position), - duration: toMs(raw.duration), + position: toDisplayPositionMs(toMs(raw.position)), + duration: toDisplayDurationMs(toMs(raw.duration)), volume: raw.volume, isFinished: raw.isFinished, }, diff --git a/electron/main/services/cue.test.ts b/electron/main/services/cue.test.ts new file mode 100644 index 00000000..33e4393c --- /dev/null +++ b/electron/main/services/cue.test.ts @@ -0,0 +1,42 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { parseCueSheet, toCueTrackPath } from "./cue"; + +const sample = ` +REM GENRE Classical +PERFORMER "Serge Prokofiev" +TITLE "Archive Recordings" +FILE "Serge Prokofiev - Archive Recordings.flac" WAVE + TRACK 01 AUDIO + TITLE "Piano Sonata No. 3 in A Minor, Op. 28" + PERFORMER "Serge Prokofiev" + INDEX 01 00:00:00 + TRACK 02 AUDIO + TITLE "Piano Sonata No. 7 in B-Flat Major, Op. 83: I. Allegro inquieto" + INDEX 01 07:14:18 + TRACK 03 AUDIO + TITLE "Piano Sonata No. 7 in B-Flat Major, Op. 83: II. Andante caloroso" + INDEX 01 15:41:12 +`; + +describe("parseCueSheet", () => { + it("解析单文件 CUE 并生成分轨时间", () => { + const cuePath = path.join("C:", "Music", "Archive Recordings.cue"); + const tracks = parseCueSheet(sample, cuePath, 1_200_000); + + assert.equal(tracks.length, 3); + assert.equal(tracks[0].title, "Piano Sonata No. 3 in A Minor, Op. 28"); + assert.equal(tracks[0].artists[0].name, "Serge Prokofiev"); + assert.equal(tracks[0].album?.name, "Archive Recordings"); + assert.equal(tracks[0].track, 1); + assert.equal(tracks[0].cueStartMs, 0); + assert.equal(tracks[0].cueEndMs, 434_240); + assert.equal(tracks[0].duration, 434_240); + assert.equal(tracks[2].cueEndMs, 1_200_000); + }); + + it("生成稳定且可入库的虚拟路径", () => { + assert.equal(toCueTrackPath("D:/a/b.cue", 7), "cue://D:/a/b.cue#track=07"); + }); +}); diff --git a/electron/main/services/cue.ts b/electron/main/services/cue.ts new file mode 100644 index 00000000..0c437cdb --- /dev/null +++ b/electron/main/services/cue.ts @@ -0,0 +1,191 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Artist, Track } from "@shared/types/player"; + +export interface CueTrackInfo { + title: string; + artists: Artist[]; + album?: { name: string; artist?: string }; + track: number; + path: string; + cuePath: string; + cueAudioPath: string; + cueStartMs: number; + cueEndMs: number; + duration: number; +} + +interface CueFile { + path: string; + tracks: ParsedCueTrack[]; +} + +interface ParsedCueTrack { + number: number; + title?: string; + performer?: string; + index01?: number; +} + +interface CueSheetState { + albumTitle?: string; + albumPerformer?: string; + files: CueFile[]; +} + +const commandPattern = /^\s*([A-Z0-9]+)\s*(.*)$/i; + +/** 生成 CUE 分轨在库中的稳定虚拟路径 */ +export const toCueTrackPath = (cuePath: string, trackNumber: number): string => + `cue://${cuePath}#track=${trackNumber.toString().padStart(2, "0")}`; + +/** 判断曲目是否为 CUE 虚拟分轨 */ +export const isCueTrack = (track: Pick | null | undefined) => + !!track?.cuePath && !!track.cueAudioPath; + +/** 从 CUE 命令参数中取第一个字段,支持双引号包裹的值 */ +const readToken = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed.startsWith('"')) return trimmed.split(/\s+/)[0] ?? ""; + let token = ""; + for (let i = 1; i < trimmed.length; i++) { + const char = trimmed[i]; + if (char === '"') break; + token += char; + } + return token; +}; + +/** 解析 mm:ss:ff,CUE 帧率固定为 75fps */ +const parseIndexMs = (value: string): number | null => { + const match = value.trim().match(/^(\d+):(\d{2}):(\d{2})$/); + if (!match) return null; + const minutes = Number(match[1]); + const seconds = Number(match[2]); + const frames = Number(match[3]); + if (!Number.isFinite(minutes) || seconds > 59 || frames > 74) return null; + return (minutes * 60 + seconds) * 1000 + Math.round((frames * 1000) / 75); +}; + +/** 解析 CUE 文本为内部结构 */ +const parseState = (content: string): CueSheetState => { + const state: CueSheetState = { files: [] }; + let currentFile: CueFile | null = null; + let currentTrack: ParsedCueTrack | null = null; + + for (const rawLine of content.replace(/^\uFEFF/, "").split(/\r?\n/)) { + const match = rawLine.match(commandPattern); + if (!match) continue; + const command = match[1].toUpperCase(); + const value = match[2] ?? ""; + + if (command === "FILE") { + currentFile = { path: readToken(value), tracks: [] }; + state.files.push(currentFile); + currentTrack = null; + continue; + } + + if (command === "TRACK") { + const number = Number(value.trim().split(/\s+/)[0]); + if (!currentFile || !Number.isFinite(number)) continue; + currentTrack = { number }; + currentFile.tracks.push(currentTrack); + continue; + } + + if (command === "TITLE") { + const title = readToken(value); + if (currentTrack) currentTrack.title = title; + else state.albumTitle = title; + continue; + } + + if (command === "PERFORMER") { + const performer = readToken(value); + if (currentTrack) currentTrack.performer = performer; + else state.albumPerformer = performer; + continue; + } + + if (command === "INDEX" && currentTrack) { + const parts = value.trim().split(/\s+/); + if (parts[0] !== "01") continue; + const ms = parseIndexMs(parts[1] ?? ""); + if (ms != null) currentTrack.index01 = ms; + } + } + + return state; +}; + +/** + * 读取单文件 CUE 指向的真实音频路径。 + * @param content - CUE 文件内容 + * @param cuePath - CUE 文件路径 + */ +export const getCueAudioPath = (content: string, cuePath: string): string | null => { + const state = parseState(content); + if (state.files.length !== 1) return null; + return path.resolve(path.dirname(cuePath), state.files[0].path); +}; + +/** + * 解析单文件 CUE。 + * @param content - CUE 文件内容 + * @param cuePath - CUE 文件路径 + * @param audioDurationMs - 真实音频总时长 + */ +export const parseCueSheet = ( + content: string, + cuePath: string, + audioDurationMs: number, +): CueTrackInfo[] => { + const state = parseState(content); + if (state.files.length !== 1 || audioDurationMs <= 0) return []; + const file = state.files[0]; + const audioPath = getCueAudioPath(content, cuePath); + if (!audioPath) return []; + const indexedTracks = file.tracks + .filter((track) => track.index01 != null) + .sort((a, b) => (a.index01 ?? 0) - (b.index01 ?? 0)); + + return indexedTracks + .map((track, index): CueTrackInfo | null => { + const start = track.index01 ?? 0; + const nextStart = indexedTracks[index + 1]?.index01 ?? audioDurationMs; + const end = Math.min(audioDurationMs, nextStart); + if (end <= start) return null; + const performer = track.performer || state.albumPerformer || ""; + const artists = performer ? [{ name: performer }] : []; + const album = state.albumTitle + ? { name: state.albumTitle, artist: state.albumPerformer } + : undefined; + return { + title: track.title || `${path.basename(cuePath, path.extname(cuePath))} #${track.number}`, + artists, + album, + track: track.number, + path: toCueTrackPath(cuePath, track.number), + cuePath, + cueAudioPath: audioPath, + cueStartMs: start, + cueEndMs: end, + duration: end - start, + }; + }) + .filter((track): track is CueTrackInfo => track !== null); +}; + +/** + * 读取并解析 CUE 文件。 + * @param cuePath - CUE 文件路径 + * @param audioDurationMs - 真实音频总时长 + */ +export const readCueSheet = async ( + cuePath: string, + audioDurationMs: number, +): Promise => { + const content = await fs.readFile(cuePath, "utf8"); + return parseCueSheet(content, cuePath, audioDurationMs); +}; diff --git a/electron/main/services/scanner.ts b/electron/main/services/scanner.ts index 7dc28733..2d8b290c 100644 --- a/electron/main/services/scanner.ts +++ b/electron/main/services/scanner.ts @@ -1,10 +1,14 @@ import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; import type { JsScanEvent, JsScannedTrack } from "@splayer/audio-engine"; import { getEngine } from "./engine"; import { + getAllTracks, upsertTracks, deleteTracksByPaths, getFileRecords, + getCueTrackPathsByDirs, type UpsertTrack, } from "@main/database"; import { broadcast } from "@main/utils/broadcast"; @@ -13,9 +17,16 @@ import { toMs } from "@main/utils/time"; import { parseArtists, parseAlbum } from "@main/utils/metadata"; import { getCoverCacheDir } from "@main/utils/config"; import { libraryLog } from "@main/utils/logger"; +import { getCueAudioPath, parseCueSheet } from "./cue"; let scanning = false; +/** 路径比较键,Windows 下保持大小写不敏感 */ +const pathKey = (value: string): string => { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +}; + /** * Rust 扫描/探测结果 → 数据库 Upsert 记录 * id 由文件路径哈希生成,标签编辑回灌与扫描共用此规则 @@ -42,6 +53,102 @@ export const scannedToUpsert = (track: JsScannedTrack): UpsertTrack => { }; }; +/** 递归查找目录下的 CUE 文件 */ +const findCueFiles = async (dirs: string[]): Promise => { + const files: string[] = []; + const walk = async (dir: string): Promise => { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + libraryLog.warn(`读取 CUE 目录失败 [${dir}]:`, error); + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".cue")) { + files.push(fullPath); + } + } + }; + for (const dir of dirs) await walk(dir); + return files; +}; + +/** 同步 CUE 虚拟曲目到曲库 */ +const syncCueTracks = async (dirs: string[]): Promise => { + const audioTracks = getAllTracks().filter((track) => track.source === "local" && !track.cuePath); + const audioByPath = new Map( + audioTracks.flatMap((track) => (track.path ? [[pathKey(track.path), track]] : [])), + ); + const cueFiles = await findCueFiles(dirs); + const upserts: UpsertTrack[] = []; + const nextPaths = new Set(); + + for (const cuePath of cueFiles) { + try { + const cueStat = await fs.stat(cuePath); + const content = await fs.readFile(cuePath, "utf8"); + const audioPath = getCueAudioPath(content, cuePath); + if (!audioPath) continue; + const audio = audioByPath.get(pathKey(audioPath)); + if (!audio || audio.duration <= 0) continue; + const cueTracks = parseCueSheet(content, cuePath, audio.duration); + for (const cueTrack of cueTracks) { + const id = createHash("sha256").update(cueTrack.path).digest("hex").slice(0, 16); + nextPaths.add(cueTrack.path); + upserts.push({ + id, + path: cueTrack.path, + cuePath: cueTrack.cuePath, + cueAudioPath: cueTrack.cueAudioPath, + cueStartMs: cueTrack.cueStartMs, + cueEndMs: cueTrack.cueEndMs, + title: cueTrack.title, + track: cueTrack.track, + artists: cueTrack.artists, + album: cueTrack.album, + duration: cueTrack.duration, + cover: audio.cover, + codec: audio.quality?.codec, + sampleRate: audio.quality?.sampleRate, + bitRate: audio.quality?.bitRate, + channels: audio.quality?.channels, + bitsPerSample: audio.quality?.bitsPerSample, + fileSize: audio.fileSize ?? cueStat.size, + mtime: cueStat.mtimeMs, + ctime: cueStat.ctimeMs, + }); + } + } catch (error) { + libraryLog.warn(`解析 CUE 失败 [${cuePath}]:`, error); + } + } + + if (upserts.length > 0) upsertTracks(upserts); + const stalePaths = getCueTrackPathsByDirs(dirs).filter((trackPath) => !nextPaths.has(trackPath)); + if (stalePaths.length > 0) deleteTracksByPaths(stalePaths); + return upserts.length; +}; + +/** 完成 Rust 扫描后的收尾同步 */ +const finishScan = async (dirs: string[], event: JsScanEvent): Promise => { + if (event.removedPaths && event.removedPaths.length > 0) { + deleteTracksByPaths(event.removedPaths); + libraryLog.info(`清理 ${event.removedPaths.length} 个已删除文件`); + } + const cueCount = await syncCueTracks(dirs); + scanning = false; + broadcast("library:scanProgress", { + phase: "done", + total: event.total, + scanned: event.scanned, + }); + libraryLog.info(`扫描完成: ${event.scanned}/${event.total} 个文件,CUE 分轨 ${cueCount} 首`); +}; + /** 是否正在扫描 */ export const isScanning = (): boolean => scanning; @@ -87,18 +194,15 @@ export const startScan = (dirs: string[], incremental = true): void => { break; } case "done": { - // 清理已删除的文件 - if (event.removedPaths && event.removedPaths.length > 0) { - deleteTracksByPaths(event.removedPaths); - libraryLog.info(`清理 ${event.removedPaths.length} 个已删除文件`); - } - scanning = false; - broadcast("library:scanProgress", { - phase: "done", - total: event.total, - scanned: event.scanned, + void finishScan(dirs, event).catch((error) => { + scanning = false; + libraryLog.error("扫描收尾失败:", error); + broadcast("library:scanProgress", { + phase: "done", + total: event.total, + scanned: event.scanned, + }); }); - libraryLog.info(`扫描完成: ${event.scanned}/${event.total} 个文件`); break; } } diff --git a/shared/types/player.ts b/shared/types/player.ts index 0fd16a33..69791ac8 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -74,6 +74,14 @@ export interface Track { source: TrackSource; /** 本地路径 */ path?: string; + /** CUE 文件路径 */ + cuePath?: string; + /** CUE 分轨对应的真实音频路径 */ + cueAudioPath?: string; + /** CUE 分轨开始时间(毫秒) */ + cueStartMs?: number; + /** CUE 分轨结束时间(毫秒) */ + cueEndMs?: number; /** 流媒体服务器实例 ID(仅 source==='streaming') */ serverId?: string; /** 流媒体服务器原生 ID(仅 source==='streaming') */ diff --git a/src/components/modals/TagEditorDialog.vue b/src/components/modals/TagEditorDialog.vue index d2acdcd3..c39d826c 100644 --- a/src/components/modals/TagEditorDialog.vue +++ b/src/components/modals/TagEditorDialog.vue @@ -18,7 +18,9 @@ const emit = defineEmits<{ "update:open": [value: boolean] }>(); const { t } = useI18n(); /** 文件名 */ -const fileName = computed(() => props.track?.path?.split(/[/\\]/).pop() ?? ""); +const fileName = computed( + () => (props.track?.cueAudioPath ?? props.track?.path)?.split(/[/\\]/).pop() ?? "", +); /** 格式 · 大小 摘要行 */ const fileMeta = computed(() => { @@ -77,7 +79,7 @@ const resetForm = (tags: TrackTags | null): void => { watch( () => props.open, async (open) => { - if (!open || !props.track?.path) return; + if (!open || !props.track?.path || props.track.cuePath) return; loading.value = true; original.value = null; resetForm(null); diff --git a/src/composables/useTrackMenu.ts b/src/composables/useTrackMenu.ts index be0d13a1..c582f787 100644 --- a/src/composables/useTrackMenu.ts +++ b/src/composables/useTrackMenu.ts @@ -71,6 +71,7 @@ export const useTrackMenu = ( const items = computed(() => { const source = track.value?.source; const isLocal = source === "local"; + const isCue = !!track.value?.cuePath; const showCloudRemove = isCloudView && track.value?.cloud === true; const canAddToPlaylist = source === "local" || source === "netease"; const isOnline = source !== "local" && source !== "streaming"; @@ -106,7 +107,7 @@ export const useTrackMenu = ( key: "editTags", label: t("songList.context.editTags"), icon: markRaw(IconSquarePen), - show: isLocal && !!options.onEditTags, + show: isLocal && !isCue && !!options.onEditTags, }, { key: "download", @@ -128,7 +129,7 @@ export const useTrackMenu = ( label: t("songList.context.deleteFile"), icon: markRaw(IconTrash2), separator: !(isPlaylist && canRemove), - show: isLocal, + show: isLocal && !isCue, }, { key: "removeFromCloud", @@ -232,10 +233,13 @@ export const useTrackMenu = ( options.onAddToPlaylist?.(current); break; case "showInExplorer": - if (current.path) window.api.system.showInExplorer(current.path); + if (current.cueAudioPath ?? current.path) { + window.api.system.showInExplorer((current.cueAudioPath ?? current.path)!); + } break; case "copyPath": - await copy(current.path); + if (current.cueAudioPath ?? current.path) + await copy((current.cueAudioPath ?? current.path)!); break; case "removeFromCollection": options.onRemove?.(current); diff --git a/src/core/player/events.ts b/src/core/player/events.ts index b6b6691c..13f7ef0c 100644 --- a/src/core/player/events.ts +++ b/src/core/player/events.ts @@ -26,6 +26,31 @@ import { /** 防止 ended 事件重入 */ let endedGuard = false; +/** 按当前播放模式结算并进入下一步 */ +const finishCurrentTrack = async (): Promise => { + const status = useStatusStore(); + if (endedGuard) return; + endedGuard = true; + try { + const stopByTimer = autoClose.onTrackEnded(); + // FM 模式跳过 + const repeatOne = status.repeatMode === "one" && !status.fmMode; + // 结算播放统计 + playStats.onTrackEnded(repeatOne && !stopByTimer); + // 定时关闭"等本曲结束"模式 + if (stopByTimer) return; + // 单曲循环:seek 回开头继续播放 + if (repeatOne) { + await seek(0); + await play(); + } else { + await nextTrack(); + } + } finally { + endedGuard = false; + } +}; + /** * 处理主进程推送的播放事件 * @param event - 播放事件 @@ -66,32 +91,17 @@ export const handleEvent = async (event: PlayerEvent): Promise => { abLoop.checkLoop(adjusted); // 推进延时缓存调度 cacheScheduler.tick(adjusted); + const track = useMediaStore().track; + if (track?.cueEndMs != null && status.isPlaying && status.duration > 0) { + if (adjusted >= status.duration - 250) await finishCurrentTrack(); + } break; } case "fftData": playback.setFftFrame(event.data); break; case "ended": { - if (endedGuard) return; - endedGuard = true; - try { - const stopByTimer = autoClose.onTrackEnded(); - // FM 模式跳过 - const repeatOne = status.repeatMode === "one" && !status.fmMode; - // 结算播放统计 - playStats.onTrackEnded(repeatOne && !stopByTimer); - // 定时关闭"等本曲结束"模式 - if (stopByTimer) break; - // 单曲循环:seek 回开头继续播放 - if (repeatOne) { - await seek(0); - await play(); - } else { - await nextTrack(); - } - } finally { - endedGuard = false; - } + await finishCurrentTrack(); break; } case "sourceError": diff --git a/src/services/audioSource.ts b/src/services/audioSource.ts index c1372934..7388048f 100644 --- a/src/services/audioSource.ts +++ b/src/services/audioSource.ts @@ -145,7 +145,8 @@ export interface ResolvedTrackSource { export const resolveTrackSource = async (track: Track): Promise => { // 本地文件 if (track.source === "local") { - return track.path ? { source: track.path, fromCache: false } : null; + const localPath = track.cueAudioPath ?? track.path; + return localPath ? { source: localPath, fromCache: false } : null; } const settings = useSettingsStore(); const songLevel = settings.player.songLevel; From 619d3acb9ea8134bf51e87472b100d999e4592c2 Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 6 Jul 2026 13:40:35 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20CUE=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=94=AF=E6=8C=81=EF=BC=8C=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E8=A2=AB=20CUE=20=E5=88=86=E8=BD=A8=E5=BC=95=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E6=95=B4=E8=BD=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/database/queries.ts | 35 ++++++++++++++++++++++--------- electron/main/ipc/library.ts | 10 +++++++-- electron/main/services/cue.ts | 23 ++------------------ electron/main/services/scanner.ts | 3 ++- src/utils/folderTree.ts | 8 ++++--- 5 files changed, 42 insertions(+), 37 deletions(-) diff --git a/electron/main/database/queries.ts b/electron/main/database/queries.ts index 72249cf2..5953b046 100644 --- a/electron/main/database/queries.ts +++ b/electron/main/database/queries.ts @@ -62,23 +62,33 @@ const rowToTrack = (row: TrackRow): Track => { }; }; -/** 查询全部曲目 */ +/** 查询全部曲目(含被 CUE 引用的容器整轨,供扫描器读取时长/封面等) */ export const getAllTracks = (): Track[] => { const rows = getDb().prepare("SELECT * FROM tracks").all() as TrackRow[]; return rows.map(rowToTrack); }; -/** 获取曲目总数 */ +/** + * 排除被 CUE 分轨引用的容器整轨文件的 WHERE 片段 + * 容器整轨(如整张原盘 flac)已被虚拟分轨取代,不应在曲库中重复出现 + * @param col - 外层曲目的 path 列(json_each 也有 path 列,带别名时须显式限定) + */ +const excludeCueContainer = (col = "path"): string => + `${col} NOT IN (SELECT cue_audio_path FROM tracks WHERE cue_audio_path IS NOT NULL)`; + +/** 获取曲目总数(不含 CUE 容器整轨) */ export const getTrackCount = (): number => { - const row = getDb().prepare("SELECT COUNT(*) as count FROM tracks").get() as { count: number }; + const row = getDb() + .prepare(`SELECT COUNT(*) as count FROM tracks WHERE ${excludeCueContainer()}`) + .get() as { count: number }; return row.count; }; /** 随机取一首曲目,库为空时返回 null */ export const getRandomTrack = (): Track | null => { - const row = getDb().prepare("SELECT * FROM tracks ORDER BY RANDOM() LIMIT 1").get() as - | TrackRow - | undefined; + const row = getDb() + .prepare(`SELECT * FROM tracks WHERE ${excludeCueContainer()} ORDER BY RANDOM() LIMIT 1`) + .get() as TrackRow | undefined; return row ? rowToTrack(row) : null; }; @@ -87,7 +97,7 @@ export const getRandomTracks = (limit: number): Track[] => { const safe = Math.max(0, Math.min(limit | 0, 500)); if (safe === 0) return []; const rows = getDb() - .prepare("SELECT * FROM tracks ORDER BY RANDOM() LIMIT ?") + .prepare(`SELECT * FROM tracks WHERE ${excludeCueContainer()} ORDER BY RANDOM() LIMIT ?`) .all(safe) as TrackRow[]; return rows.map(rowToTrack); }; @@ -204,7 +214,7 @@ export const searchTracks = (query: string): Track[] => { const pattern = `%${escaped}%`; const rows = getDb() .prepare( - "SELECT * FROM tracks WHERE title LIKE ? ESCAPE '\\' OR artists LIKE ? ESCAPE '\\' OR album LIKE ? ESCAPE '\\'", + `SELECT * FROM tracks WHERE (title LIKE ? ESCAPE '\\' OR artists LIKE ? ESCAPE '\\' OR album LIKE ? ESCAPE '\\') AND ${excludeCueContainer()}`, ) .all(pattern, pattern, pattern) as TrackRow[]; return rows.map(rowToTrack); @@ -229,6 +239,7 @@ export const getAlbumList = (): AlbumSummary[] => { COUNT(*) AS trackCount FROM tracks WHERE album IS NOT NULL AND json_extract(album, '$.name') IS NOT NULL + AND ${excludeCueContainer()} GROUP BY name`, ) .all() as { name: string; cover: string | null; artists: string; trackCount: number }[]; @@ -251,6 +262,7 @@ export const getArtistList = (): ArtistSummary[] => { FROM tracks t, json_each(t.artists) a WHERE json_extract(a.value, '$.name') IS NOT NULL AND TRIM(json_extract(a.value, '$.name')) != '' + AND ${excludeCueContainer("t.path")} GROUP BY name`, ) .all() as { name: string; trackCount: number; cover: string | null }[]; @@ -264,7 +276,9 @@ export const getArtistList = (): ArtistSummary[] => { /** 按专辑名获取全部曲目 */ export const getAlbumTracks = (albumName: string): Track[] => { const rows = getDb() - .prepare("SELECT * FROM tracks WHERE json_extract(album, '$.name') = ?") + .prepare( + `SELECT * FROM tracks WHERE json_extract(album, '$.name') = ? AND ${excludeCueContainer()}`, + ) .all(albumName) as TrackRow[]; return rows.map(rowToTrack); }; @@ -274,7 +288,8 @@ export const getArtistTracks = (artistName: string): Track[] => { const rows = getDb() .prepare( `SELECT DISTINCT t.* FROM tracks t, json_each(t.artists) a - WHERE LOWER(json_extract(a.value, '$.name')) = LOWER(?)`, + WHERE LOWER(json_extract(a.value, '$.name')) = LOWER(?) + AND ${excludeCueContainer("t.path")}`, ) .all(artistName) as TrackRow[]; return rows.map(rowToTrack); diff --git a/electron/main/ipc/library.ts b/electron/main/ipc/library.ts index 1f97726d..d10b1cd5 100644 --- a/electron/main/ipc/library.ts +++ b/electron/main/ipc/library.ts @@ -49,10 +49,16 @@ export const registerLibraryIpc = (): void => { return { success: true }; }); - // 获取全部曲目 + // 获取全部曲目(隐藏被 CUE 分轨引用的容器整轨;容器仍留在库中供扫描器读取时长) ipcMain.handle("library:getTracks", () => { try { - return { success: true, data: getAllTracks() }; + const all = getAllTracks(); + const containerPaths = new Set(); + for (const track of all) { + if (track.cueAudioPath) containerPaths.add(track.cueAudioPath); + } + const data = all.filter((track) => !track.path || !containerPaths.has(track.path)); + return { success: true, data }; } catch (_error) { return { success: false, error: ErrorCode.UNKNOWN }; } diff --git a/electron/main/services/cue.ts b/electron/main/services/cue.ts index 0c437cdb..4f582d8f 100644 --- a/electron/main/services/cue.ts +++ b/electron/main/services/cue.ts @@ -1,6 +1,5 @@ -import fs from "node:fs/promises"; import path from "node:path"; -import type { Artist, Track } from "@shared/types/player"; +import type { Artist } from "@shared/types/player"; export interface CueTrackInfo { title: string; @@ -39,10 +38,6 @@ const commandPattern = /^\s*([A-Z0-9]+)\s*(.*)$/i; export const toCueTrackPath = (cuePath: string, trackNumber: number): string => `cue://${cuePath}#track=${trackNumber.toString().padStart(2, "0")}`; -/** 判断曲目是否为 CUE 虚拟分轨 */ -export const isCueTrack = (track: Pick | null | undefined) => - !!track?.cuePath && !!track.cueAudioPath; - /** 从 CUE 命令参数中取第一个字段,支持双引号包裹的值 */ const readToken = (value: string): string => { const trimmed = value.trim(); @@ -144,8 +139,7 @@ export const parseCueSheet = ( const state = parseState(content); if (state.files.length !== 1 || audioDurationMs <= 0) return []; const file = state.files[0]; - const audioPath = getCueAudioPath(content, cuePath); - if (!audioPath) return []; + const audioPath = path.resolve(path.dirname(cuePath), file.path); const indexedTracks = file.tracks .filter((track) => track.index01 != null) .sort((a, b) => (a.index01 ?? 0) - (b.index01 ?? 0)); @@ -176,16 +170,3 @@ export const parseCueSheet = ( }) .filter((track): track is CueTrackInfo => track !== null); }; - -/** - * 读取并解析 CUE 文件。 - * @param cuePath - CUE 文件路径 - * @param audioDurationMs - 真实音频总时长 - */ -export const readCueSheet = async ( - cuePath: string, - audioDurationMs: number, -): Promise => { - const content = await fs.readFile(cuePath, "utf8"); - return parseCueSheet(content, cuePath, audioDurationMs); -}; diff --git a/electron/main/services/scanner.ts b/electron/main/services/scanner.ts index 2d8b290c..600f225c 100644 --- a/electron/main/services/scanner.ts +++ b/electron/main/services/scanner.ts @@ -103,7 +103,8 @@ const syncCueTracks = async (dirs: string[]): Promise => { id, path: cueTrack.path, cuePath: cueTrack.cuePath, - cueAudioPath: cueTrack.cueAudioPath, + // 存容器整轨的真实入库路径(而非 CUE FILE 行解析值),保证与容器行 path 精确相等,据此隐藏容器 + cueAudioPath: audio.path, cueStartMs: cueTrack.cueStartMs, cueEndMs: cueTrack.cueEndMs, title: cueTrack.title, diff --git a/src/utils/folderTree.ts b/src/utils/folderTree.ts index abb17925..0f6fc6e6 100644 --- a/src/utils/folderTree.ts +++ b/src/utils/folderTree.ts @@ -58,9 +58,11 @@ export const buildFolderTree = ( }; for (const track of tracks) { - if (!track.path) continue; - const fullPath = normalizePath(track.path); - const rootPath = findScanRoot(track.path); + // CUE 虚拟分轨的 path 是 cue://... 协议路径,按其真实容器文件路径归入磁盘目录 + const diskPath = track.cueAudioPath ?? track.path; + if (!diskPath) continue; + const fullPath = normalizePath(diskPath); + const rootPath = findScanRoot(diskPath); const rootNode = roots.get(rootPath) ?? ensureFolder(rootPath, folderBasename(rootPath) || rootPath); if (!roots.has(rootPath)) roots.set(rootPath, rootNode); From a9ce3c3bda6db05ef18ad0f72fe00aca938bb60c Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 6 Jul 2026 14:39:44 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20CUE=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E6=94=B6=E9=9B=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E6=89=AB=E6=8F=8F=E8=BF=87?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/services/scanner.ts | 54 +++++++++++++----------------- native/audio-engine/index.d.ts | 2 ++ native/audio-engine/src/lib.rs | 4 +++ native/audio-engine/src/scanner.rs | 21 +++++++++++- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/electron/main/services/scanner.ts b/electron/main/services/scanner.ts index 600f225c..8903ac19 100644 --- a/electron/main/services/scanner.ts +++ b/electron/main/services/scanner.ts @@ -53,43 +53,37 @@ export const scannedToUpsert = (track: JsScannedTrack): UpsertTrack => { }; }; -/** 递归查找目录下的 CUE 文件 */ -const findCueFiles = async (dirs: string[]): Promise => { - const files: string[] = []; - const walk = async (dir: string): Promise => { - let entries: import("node:fs").Dirent[]; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch (error) { - libraryLog.warn(`读取 CUE 目录失败 [${dir}]:`, error); - return; - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - await walk(fullPath); - } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".cue")) { - files.push(fullPath); - } - } - }; - for (const dir of dirs) await walk(dir); - return files; -}; - /** 同步 CUE 虚拟曲目到曲库 */ -const syncCueTracks = async (dirs: string[]): Promise => { - const audioTracks = getAllTracks().filter((track) => track.source === "local" && !track.cuePath); +const syncCueTracks = async (cueFiles: string[], dirs: string[]): Promise => { + const allTracks = getAllTracks(); + // 真实音频文件 const audioByPath = new Map( - audioTracks.flatMap((track) => (track.path ? [[pathKey(track.path), track]] : [])), + allTracks.flatMap((track) => + !track.cuePath && track.path ? [[pathKey(track.path), track] as const] : [], + ), ); - const cueFiles = await findCueFiles(dirs); + // 跳过未变化的 CUE + const existingByCue = new Map(); + for (const track of allTracks) { + if (!track.cuePath || !track.path) continue; + const key = pathKey(track.cuePath); + const group = existingByCue.get(key); + if (group) group.paths.push(track.path); + else existingByCue.set(key, { mtime: track.mtime ?? -1, paths: [track.path] }); + } + const upserts: UpsertTrack[] = []; const nextPaths = new Set(); for (const cuePath of cueFiles) { try { const cueStat = await fs.stat(cuePath); + const existing = existingByCue.get(pathKey(cuePath)); + // .cue 未变化 + if (existing && existing.mtime === cueStat.mtimeMs) { + for (const trackPath of existing.paths) nextPaths.add(trackPath); + continue; + } const content = await fs.readFile(cuePath, "utf8"); const audioPath = getCueAudioPath(content, cuePath); if (!audioPath) continue; @@ -131,7 +125,7 @@ const syncCueTracks = async (dirs: string[]): Promise => { if (upserts.length > 0) upsertTracks(upserts); const stalePaths = getCueTrackPathsByDirs(dirs).filter((trackPath) => !nextPaths.has(trackPath)); if (stalePaths.length > 0) deleteTracksByPaths(stalePaths); - return upserts.length; + return nextPaths.size; }; /** 完成 Rust 扫描后的收尾同步 */ @@ -140,7 +134,7 @@ const finishScan = async (dirs: string[], event: JsScanEvent): Promise => deleteTracksByPaths(event.removedPaths); libraryLog.info(`清理 ${event.removedPaths.length} 个已删除文件`); } - const cueCount = await syncCueTracks(dirs); + const cueCount = await syncCueTracks(event.cueFiles ?? [], dirs); scanning = false; broadcast("library:scanProgress", { phase: "done", diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index 0604dfe5..b4fd6b06 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -199,6 +199,8 @@ export interface JsScanEvent { tracks?: Array /** 已删除的文件路径列表(仅 done 事件) */ removedPaths?: Array + /** 遍历时收集到的 CUE 文件路径(仅 done 事件) */ + cueFiles?: Array } /** 扫描到的曲目信息 */ diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 748b9397..8ce8142a 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -729,6 +729,8 @@ pub struct JsScanEvent { pub tracks: Option>, /// 已删除的文件路径列表(仅 done 事件) pub removed_paths: Option>, + /// 遍历时收集到的 CUE 文件路径(仅 done 事件) + pub cue_files: Option>, } /// 批量扫描目录,通过回调推送进度和结果 @@ -781,11 +783,13 @@ pub fn scan_dirs( scanned, total, removed_paths, + cue_files, } => JsScanEvent { event_type: "done".into(), scanned, total, removed_paths: Some(removed_paths), + cue_files: Some(cue_files), ..Default::default() }, }; diff --git a/native/audio-engine/src/scanner.rs b/native/audio-engine/src/scanner.rs index dab2ec16..8ab47a89 100644 --- a/native/audio-engine/src/scanner.rs +++ b/native/audio-engine/src/scanner.rs @@ -64,6 +64,8 @@ pub enum ScanEvent { scanned: u32, total: u32, removed_paths: Vec, + /// 遍历时顺带收集到的 CUE 文件路径 + cue_files: Vec, }, } @@ -74,6 +76,13 @@ fn is_audio_file(path: &Path) -> bool { .is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) } +/// 判断文件是否为 CUE 分轨描述文件 +fn is_cue_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("cue")) +} + /// 使用 ffmpeg_audio 打开音频文件并读取元数据 /// /// 新 API 下 AudioReader 不再要求重采样参数,扫库每文件省一次 SwrContext 分配 @@ -172,6 +181,7 @@ pub fn scan_directories( let walk_start = Instant::now(); let mut audio_files: Vec<(String, u64, u64, u64)> = Vec::new(); let mut scanned_paths: Vec = Vec::new(); + let mut cue_files: Vec = Vec::new(); // 本轮不可达的目录(NAS 掉线 / 移动硬盘未挂载),其下已有记录不得报告为已删除 let mut unavailable_dirs: Vec<&str> = Vec::new(); @@ -188,7 +198,14 @@ pub fn scan_directories( } for entry in WalkDir::new(dir).follow_links(true).into_iter().flatten() { let path = entry.path(); - if !path.is_file() || !is_audio_file(path) { + if !path.is_file() { + continue; + } + if is_cue_file(path) { + cue_files.push(path.to_string_lossy().into_owned()); + continue; + } + if !is_audio_file(path) { continue; } let path_str = path.to_string_lossy().into_owned(); @@ -226,6 +243,7 @@ pub fn scan_directories( scanned, total, removed_paths: Vec::new(), + cue_files: std::mem::take(&mut cue_files), }); return; } @@ -299,5 +317,6 @@ pub fn scan_directories( scanned, total, removed_paths, + cue_files, }); } From 1188e689f7227cd26499fe9b92bf69baa539989f Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 6 Jul 2026 18:27:44 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=BE=BE=E6=89=AB=E6=8F=8F=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=9A=84=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96=20CUE=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/services/scanner.ts | 19 ++++++++++++++++--- native/audio-engine/index.d.ts | 2 ++ native/audio-engine/src/lib.rs | 4 ++++ native/audio-engine/src/scanner.rs | 4 ++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/electron/main/services/scanner.ts b/electron/main/services/scanner.ts index 8903ac19..6afdae36 100644 --- a/electron/main/services/scanner.ts +++ b/electron/main/services/scanner.ts @@ -54,7 +54,11 @@ export const scannedToUpsert = (track: JsScannedTrack): UpsertTrack => { }; /** 同步 CUE 虚拟曲目到曲库 */ -const syncCueTracks = async (cueFiles: string[], dirs: string[]): Promise => { +const syncCueTracks = async ( + cueFiles: string[], + dirs: string[], + unavailableDirs: string[] = [], +): Promise => { const allTracks = getAllTracks(); // 真实音频文件 const audioByPath = new Map( @@ -119,11 +123,20 @@ const syncCueTracks = async (cueFiles: string[], dirs: string[]): Promise 0) upsertTracks(upserts); - const stalePaths = getCueTrackPathsByDirs(dirs).filter((trackPath) => !nextPaths.has(trackPath)); + const stalePaths = getCueTrackPathsByDirs(dirs).filter((trackPath) => { + if (nextPaths.has(trackPath)) return false; + // 不可达目录下的分轨不删除 + return !unavailableDirs.some((dir) => trackPath.startsWith(dir)); + }); if (stalePaths.length > 0) deleteTracksByPaths(stalePaths); return nextPaths.size; }; @@ -134,7 +147,7 @@ const finishScan = async (dirs: string[], event: JsScanEvent): Promise => deleteTracksByPaths(event.removedPaths); libraryLog.info(`清理 ${event.removedPaths.length} 个已删除文件`); } - const cueCount = await syncCueTracks(event.cueFiles ?? [], dirs); + const cueCount = await syncCueTracks(event.cueFiles ?? [], dirs, event.unavailableDirs ?? []); scanning = false; broadcast("library:scanProgress", { phase: "done", diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index b4fd6b06..b013ae07 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -201,6 +201,8 @@ export interface JsScanEvent { removedPaths?: Array /** 遍历时收集到的 CUE 文件路径(仅 done 事件) */ cueFiles?: Array + /** 不可达的扫描目录 */ + unavailableDirs?: Array } /** 扫描到的曲目信息 */ diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 8ce8142a..c624c9ae 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -731,6 +731,8 @@ pub struct JsScanEvent { pub removed_paths: Option>, /// 遍历时收集到的 CUE 文件路径(仅 done 事件) pub cue_files: Option>, + /// 不可达的扫描目录 + pub unavailable_dirs: Option>, } /// 批量扫描目录,通过回调推送进度和结果 @@ -784,12 +786,14 @@ pub fn scan_dirs( total, removed_paths, cue_files, + unavailable_dirs, } => JsScanEvent { event_type: "done".into(), scanned, total, removed_paths: Some(removed_paths), cue_files: Some(cue_files), + unavailable_dirs: Some(unavailable_dirs), ..Default::default() }, }; diff --git a/native/audio-engine/src/scanner.rs b/native/audio-engine/src/scanner.rs index 8ab47a89..c32ec2a0 100644 --- a/native/audio-engine/src/scanner.rs +++ b/native/audio-engine/src/scanner.rs @@ -66,6 +66,8 @@ pub enum ScanEvent { removed_paths: Vec, /// 遍历时顺带收集到的 CUE 文件路径 cue_files: Vec, + /// 不可达的扫描目录 + unavailable_dirs: Vec, }, } @@ -244,6 +246,7 @@ pub fn scan_directories( total, removed_paths: Vec::new(), cue_files: std::mem::take(&mut cue_files), + unavailable_dirs: unavailable_dirs.iter().map(|s| s.to_string()).collect(), }); return; } @@ -318,5 +321,6 @@ pub fn scan_directories( total, removed_paths, cue_files, + unavailable_dirs: unavailable_dirs.iter().map(|s| s.to_string()).collect(), }); } From c7cf1638494223dda5c1b4d7d03fca106f160565 Mon Sep 17 00:00:00 2001 From: imsyy Date: Mon, 6 Jul 2026 22:00:06 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E8=BE=BE=E7=9B=AE=E5=BD=95=E4=B8=8B=20CUE=20=E5=88=86=E8=BD=A8?= =?UTF-8?q?=E8=AF=AF=E5=88=A0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 extractCuePath 函数从虚拟路径提取真实 cuePath - 使用真实 cuePath 做目录前缀判断,而非虚拟路径 - 确保 NAS/移动盘等不可达目录下的 CUE 分轨不被误删 --- electron/main/services/cue.ts | 10 ++++++++++ electron/main/services/scanner.ts | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/electron/main/services/cue.ts b/electron/main/services/cue.ts index 4f582d8f..38e3551d 100644 --- a/electron/main/services/cue.ts +++ b/electron/main/services/cue.ts @@ -38,6 +38,16 @@ const commandPattern = /^\s*([A-Z0-9]+)\s*(.*)$/i; export const toCueTrackPath = (cuePath: string, trackNumber: number): string => `cue://${cuePath}#track=${trackNumber.toString().padStart(2, "0")}`; +/** + * 从 CUE 虚拟路径中提取真实的 CUE 文件路径 + * @param cueTrackPath - 虚拟路径,格式为 `cue://{cuePath}#track={number}` + * @returns 真实的 CUE 文件路径,格式不匹配时返回 null + */ +export const extractCuePath = (cueTrackPath: string): string | null => { + const match = cueTrackPath.match(/^cue:\/\/(.+)#track=\d{2}$/); + return match?.[1] ?? null; +}; + /** 从 CUE 命令参数中取第一个字段,支持双引号包裹的值 */ const readToken = (value: string): string => { const trimmed = value.trim(); diff --git a/electron/main/services/scanner.ts b/electron/main/services/scanner.ts index 6afdae36..8bd8e92d 100644 --- a/electron/main/services/scanner.ts +++ b/electron/main/services/scanner.ts @@ -17,7 +17,7 @@ import { toMs } from "@main/utils/time"; import { parseArtists, parseAlbum } from "@main/utils/metadata"; import { getCoverCacheDir } from "@main/utils/config"; import { libraryLog } from "@main/utils/logger"; -import { getCueAudioPath, parseCueSheet } from "./cue"; +import { getCueAudioPath, parseCueSheet, extractCuePath } from "./cue"; let scanning = false; @@ -134,8 +134,10 @@ const syncCueTracks = async ( if (upserts.length > 0) upsertTracks(upserts); const stalePaths = getCueTrackPathsByDirs(dirs).filter((trackPath) => { if (nextPaths.has(trackPath)) return false; - // 不可达目录下的分轨不删除 - return !unavailableDirs.some((dir) => trackPath.startsWith(dir)); + // 不可达目录下的分轨不删除:从虚拟路径提取真实 cuePath 做目录判断 + const realCuePath = extractCuePath(trackPath); + if (!realCuePath) return true; + return !unavailableDirs.some((dir) => realCuePath.startsWith(dir)); }); if (stalePaths.length > 0) deleteTracksByPaths(stalePaths); return nextPaths.size; From 1aa92409aca53ac933f0536f081f61b0e752f81b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:02:52 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20extractCuePath?= =?UTF-8?q?=20=E6=AD=A3=E5=88=99=E4=BB=A5=E6=94=AF=E6=8C=81=E8=BD=A8?= =?UTF-8?q?=E5=8F=B7=20=E2=89=A5=20100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/services/cue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/main/services/cue.ts b/electron/main/services/cue.ts index 38e3551d..f9dbc43f 100644 --- a/electron/main/services/cue.ts +++ b/electron/main/services/cue.ts @@ -44,7 +44,7 @@ export const toCueTrackPath = (cuePath: string, trackNumber: number): string => * @returns 真实的 CUE 文件路径,格式不匹配时返回 null */ export const extractCuePath = (cueTrackPath: string): string | null => { - const match = cueTrackPath.match(/^cue:\/\/(.+)#track=\d{2}$/); + const match = cueTrackPath.match(/^cue:\/\/(.+)#track=\d+$/); return match?.[1] ?? null; };