Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions electron/main/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '[]',
Expand Down Expand Up @@ -150,6 +154,7 @@ export {
getAllTracks,
getTrackCount,
getFileRecords,
getCueTrackPathsByDirs,
upsertTracks,
deleteTracksByPaths,
searchTracks,
Expand Down
19 changes: 18 additions & 1 deletion electron/main/database/migration.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type Database from "better-sqlite3";

/** 当前 schema 版本 */
const SCHEMA_VERSION = 3;
const SCHEMA_VERSION = 4;

type TableInfoRow = { name: string };

Expand Down Expand Up @@ -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")) {
Expand Down
81 changes: 63 additions & 18 deletions electron/main/database/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[],
Expand All @@ -54,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;
};

Expand All @@ -79,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);
};
Expand All @@ -91,17 +109,23 @@ 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[];
};

/** 批量插入/更新的扫描结果 */
export interface UpsertTrack {
id: string;
path: string;
cuePath?: string;
cueAudioPath?: string;
cueStartMs?: number;
cueEndMs?: number;
title: string;
track?: number;
artists: Artist[];
Expand All @@ -124,16 +148,20 @@ 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(() => {
for (const t of tracks) {
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),
Expand All @@ -155,14 +183,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();
Expand All @@ -174,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);
Expand All @@ -184,8 +224,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 + "%");
};

/** 专辑列表 */
Expand All @@ -199,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 }[];
Expand All @@ -221,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 }[];
Expand All @@ -234,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);
};
Expand All @@ -244,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);
Expand Down
10 changes: 8 additions & 2 deletions electron/main/ipc/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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 };
}
Expand Down
Loading