Skip to content
Open
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
1 change: 1 addition & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ declare module 'vue' {
IconLucideGithub: typeof import('~icons/lucide/github')['default']
IconLucideGlobe2: typeof import('~icons/lucide/globe2')['default']
IconLucideGripVertical: typeof import('~icons/lucide/grip-vertical')['default']
IconLucideGuitar: typeof import('~icons/lucide/guitar')['default']
IconLucideHardDrive: typeof import('~icons/lucide/hard-drive')['default']
IconLucideHeadphones: typeof import('~icons/lucide/headphones')['default']
IconLucideHeartOff: typeof import('~icons/lucide/heart-off')['default']
Expand Down
4 changes: 4 additions & 0 deletions electron/main/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const initDatabase = (): void => {
track INTEGER,
artists TEXT NOT NULL DEFAULT '[]',
album TEXT,
genres TEXT,
duration INTEGER NOT NULL,
cover TEXT,
codec TEXT,
Expand Down Expand Up @@ -241,6 +242,9 @@ export {
deleteTracksByDir,
getAlbumList,
getArtistList,
getGenreList,
getGenreTracks,
hasTracksMissingGenres,
getAlbumTracks,
getArtistTracks,
getTracksByIds,
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 = 4;
const SCHEMA_VERSION = 6;

type TableInfoRow = { name: string };

Expand Down Expand Up @@ -52,6 +52,23 @@ export const migrate = (d: Database.Database): void => {
v = 4;
}

// v4 → v5: 添加流派列
if (v < 5) {
if (!hasColumn(d, "tracks", "genres")) {
// 不设默认值:NULL 表示该曲目尚未解析过流派,扫描器据此触发一次全量回填
d.exec("ALTER TABLE tracks ADD COLUMN genres TEXT");
}
v = 5;
}

// v5 → v6: 早期版本给 genres 写入了空数组,无法区分「无流派」与「未解析」,
// 这里统一清空以触发一次全量回填(扫描器检测到空值会自动降级为全量扫描)
// 用空串而非 NULL:早期版本建的列可能带 NOT NULL 约束
if (v < 6) {
d.exec("UPDATE tracks SET genres = ''");
v = 6;
}

// 版本无关部分
// 补 lyric_match_cache.extra 列
if (!hasColumn(d, "lyric_match_cache", "extra")) {
Expand Down
77 changes: 71 additions & 6 deletions electron/main/database/queries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from "node:path";
import type { Track, Artist, Album, AudioQuality } from "@shared/types/player";
import type { AlbumSummary, ArtistSummary } from "@shared/types/library";
import type { AlbumSummary, ArtistSummary, GenreSummary } from "@shared/types/library";
import type { LibraryStats } from "@shared/types/stats";
import { getDb } from "./index";

Expand All @@ -16,6 +16,7 @@ interface TrackRow {
track?: number;
artists: string;
album: string | null;
genres: string | null;
duration: number;
cover: string | null;
codec: string | null;
Expand Down Expand Up @@ -54,6 +55,7 @@ const rowToTrack = (row: TrackRow): Track => {
track: row.track ?? undefined,
artists: JSON.parse(row.artists) as Artist[],
album: row.album ? (JSON.parse(row.album) as Album) : undefined,
genres: row.genres ? (JSON.parse(row.genres) as string[]) : [],
duration: row.duration,
cover: row.cover ?? undefined,
fileSize: row.file_size ?? undefined,
Expand Down Expand Up @@ -131,6 +133,8 @@ export interface UpsertTrack {
track?: number;
artists: Artist[];
album?: Album;
/** 流派列表(已解析拆分) */
genres?: string[];
duration: number;
cover?: string;
codec?: string;
Expand All @@ -149,9 +153,9 @@ export const upsertTracks = (tracks: UpsertTrack[]): void => {
const d = getDb();
const stmt = d.prepare(`
INSERT OR REPLACE INTO tracks
(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)
(id, path, cue_path, cue_audio_path, cue_start_ms, cue_end_ms, title, track, artists, album, genres, 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(() => {
Expand All @@ -167,6 +171,7 @@ export const upsertTracks = (tracks: UpsertTrack[]): void => {
t.track ?? null,
JSON.stringify(t.artists),
t.album ? JSON.stringify(t.album) : null,
JSON.stringify(t.genres ?? []),
t.duration,
t.cover ?? null,
t.codec ?? null,
Expand Down Expand Up @@ -217,15 +222,17 @@ export const deleteTracksByPaths = (paths: string[]): void => {
tx();
};

/** 模糊搜索曲目(title / artists / album) */
/** 模糊搜索曲目(title / artists / album / genres) */
export const searchTracks = (query: string): Track[] => {
const escaped = query.replace(/[%_\\]/g, "\\$&");
const pattern = `%${escaped}%`;
const rows = getDb()
.prepare(
`SELECT * FROM tracks WHERE (title LIKE ? ESCAPE '\\' OR artists LIKE ? ESCAPE '\\' OR album LIKE ? ESCAPE '\\') AND ${excludeCueContainer()}`,
`SELECT * FROM tracks
WHERE (title LIKE ? ESCAPE '\\' OR artists LIKE ? ESCAPE '\\' OR album LIKE ? ESCAPE '\\' OR genres LIKE ? ESCAPE '\\')
AND ${excludeCueContainer()}`,
)
.all(pattern, pattern, pattern) as TrackRow[];
.all(pattern, pattern, pattern, pattern) as TrackRow[];
return rows.map(rowToTrack);
};

Expand Down Expand Up @@ -295,6 +302,41 @@ export const getArtistList = (): ArtistSummary[] => {
}));
};

/** 流派列表 */
export const getGenreList = (): GenreSummary[] => {
const rows = getDb()
.prepare(
`SELECT
MIN(g.value) AS name,
COUNT(DISTINCT t.id) AS trackCount,
MAX(CASE WHEN t.cover IS NOT NULL THEN t.cover END) AS cover
FROM tracks t, json_each(COALESCE(NULLIF(t.genres, ''), '[]')) g
WHERE g.value IS NOT NULL
AND TRIM(g.value) != ''
AND ${excludeCueContainer("t.path")}
GROUP BY LOWER(g.value)
ORDER BY trackCount DESC, name`,
)
.all() as { name: string; trackCount: number; cover: string | null }[];
return rows.map((row) => ({
name: row.name,
trackCount: row.trackCount,
cover: row.cover ?? undefined,
}));
};

/** 按流派名获取全部曲目 */
export const getGenreTracks = (genreName: string): Track[] => {
const rows = getDb()
.prepare(
`SELECT DISTINCT t.* FROM tracks t, json_each(COALESCE(NULLIF(t.genres, ''), '[]')) g
WHERE LOWER(g.value) = LOWER(?)
AND ${excludeCueContainer("t.path")}`,
)
.all(genreName) as TrackRow[];
return rows.map(rowToTrack);
};

/** 按专辑名获取全部曲目 */
export const getAlbumTracks = (albumName: string): Track[] => {
const rows = getDb()
Expand Down Expand Up @@ -349,6 +391,16 @@ export const getLibraryStats = (): LibraryStats => {
)
.get() as { count: number };

const genreRow = d
.prepare(
`SELECT COUNT(DISTINCT LOWER(g.value)) AS count
FROM tracks t, json_each(COALESCE(NULLIF(t.genres, ''), '[]')) g
WHERE g.value IS NOT NULL
AND TRIM(g.value) != ''
AND ${excludeCueContainer("t.path")}`,
)
.get() as { count: number };

const codecs = d
.prepare(
`SELECT COALESCE(codec, '') AS codec, COUNT(*) AS count
Expand All @@ -363,12 +415,25 @@ export const getLibraryStats = (): LibraryStats => {
trackCount: aggregate.trackCount,
albumCount: albumRow.count,
artistCount: artistRow.count,
genreCount: genreRow.count,
totalDurationMs: aggregate.totalDurationMs,
totalFileSize: aggregate.totalFileSize,
codecs,
};
};

/**
* 是否存在尚未解析过流派的曲目
* 空值(NULL 或空串)表示该曲目还没解析过流派,用于触发一次全量扫描回填历史曲目
* @returns 存在待回填曲目时返回 true
*/
export const hasTracksMissingGenres = (): boolean => {
const row = getDb()
.prepare("SELECT EXISTS(SELECT 1 FROM tracks WHERE genres IS NULL OR genres = '') AS missing")
.get() as { missing: number };
return row.missing === 1;
};

/** 按 ID 批量获取曲目 */
export const getTracksByIds = (ids: string[]): Track[] => {
if (ids.length === 0) return [];
Expand Down
20 changes: 20 additions & 0 deletions electron/main/ipc/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
deleteTracksByPaths,
getAlbumList,
getArtistList,
getGenreList,
getGenreTracks,
getAlbumTracks,
getArtistTracks,
getTracksByIds,
Expand Down Expand Up @@ -82,6 +84,24 @@ export const registerLibraryIpc = (): void => {
}
});

// 获取流派聚合列表
ipcMain.handle("library:getGenres", () => {
try {
return { success: true, data: getGenreList() };
} catch (_error) {
return { success: false, error: ErrorCode.UNKNOWN };
}
});

// 获取某流派下的全部曲目
ipcMain.handle("library:getGenreTracks", (_event, genreName: string) => {
try {
return { success: true, data: getGenreTracks(genreName) };
} catch (_error) {
return { success: false, error: ErrorCode.UNKNOWN };
}
});

// 获取某专辑下的全部曲目
ipcMain.handle("library:getAlbumTracks", (_event, albumName: string) => {
try {
Expand Down
9 changes: 8 additions & 1 deletion electron/main/ipc/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ import { getMainWindow, setTaskbarProgress } from "@main/window";
import { store } from "@main/store";
import { appName, getSongCacheDir } from "@main/utils/config";
import * as songCache from "@main/services/songCache";
import { parseArtists, parseAlbum, formatArtists, artistNames } from "@main/utils/metadata";
import {
parseArtists,
parseAlbum,
parseGenres,
formatArtists,
artistNames,
} from "@main/utils/metadata";
import { playerLog } from "@main/utils/logger";
import { updatePowerBlocker, releasePowerBlocker } from "@main/utils/powerBlocker";
import { ErrorCode } from "@shared/types/errors";
Expand Down Expand Up @@ -349,6 +355,7 @@ const completeTrackLoad = (
? authoritative.artists
: parseArtists(meta.artist ?? ""),
album: authoritative?.album ?? parseAlbum(meta.album ?? ""),
genres: authoritative?.genres?.length ? authoritative.genres : parseGenres(meta.genre),
duration: durationMs,
cover: isRemote ? undefined : toCacheUrl(meta.cover),
quality,
Expand Down
7 changes: 7 additions & 0 deletions electron/main/services/cue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ describe("parseCueSheet", () => {
assert.equal(tracks[2].cueEndMs, 1_200_000);
});

it("从 REM GENRE 解析流派", () => {
const cuePath = path.join("C:", "Music", "Archive Recordings.cue");
const tracks = parseCueSheet(sample, cuePath, 1_200_000);

assert.deepEqual(tracks[0].genres, ["Classical"]);
});

it("生成稳定且可入库的虚拟路径", () => {
assert.equal(toCueTrackPath("D:/a/b.cue", 7), "cue://D:/a/b.cue#track=07");
});
Expand Down
17 changes: 17 additions & 0 deletions electron/main/services/cue.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import path from "node:path";
import type { Artist } from "@shared/types/player";
import { parseGenres } from "../utils/metadata";

export interface CueTrackInfo {
title: string;
artists: Artist[];
album?: { name: string; artist?: string };
/** 流派列表(来自 REM GENRE) */
genres: string[];
track: number;
path: string;
cuePath: string;
Expand All @@ -23,12 +26,14 @@ interface ParsedCueTrack {
number: number;
title?: string;
performer?: string;
genre?: string;
index01?: number;
}

interface CueSheetState {
albumTitle?: string;
albumPerformer?: string;
albumGenre?: string;
files: CueFile[];
}

Expand Down Expand Up @@ -113,6 +118,17 @@ const parseState = (content: string): CueSheetState => {
continue;
}

// REM GENRE Classical:CUE 的流派写在注释指令里
if (command === "REM") {
const remMatch = value.match(commandPattern);
if (remMatch && remMatch[1].toUpperCase() === "GENRE") {
const genre = readToken(remMatch[2] ?? "") || (remMatch[2] ?? "").trim();
if (currentTrack) currentTrack.genre = genre;
else state.albumGenre = genre;
}
continue;
}

if (command === "INDEX" && currentTrack) {
const parts = value.trim().split(/\s+/);
if (parts[0] !== "01") continue;
Expand Down Expand Up @@ -169,6 +185,7 @@ export const parseCueSheet = (
title: track.title || `${path.basename(cuePath, path.extname(cuePath))} #${track.number}`,
artists,
album,
genres: parseGenres(track.genre || state.albumGenre),
track: track.number,
path: toCueTrackPath(cuePath, track.number),
cuePath,
Expand Down
16 changes: 13 additions & 3 deletions electron/main/services/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import {
deleteTracksByPaths,
getFileRecords,
getCueTrackPathsByDirs,
hasTracksMissingGenres,
type UpsertTrack,
} from "@main/database";
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 { parseArtists, parseAlbum, parseGenres } from "@main/utils/metadata";
import { getCoverCacheDir, isWin } from "@main/utils/config";
import { libraryLog } from "@main/utils/logger";
import { getCueAudioPath, parseCueSheet, extractCuePath } from "./cue";
Expand All @@ -41,6 +42,7 @@ export const scannedToUpsert = (track: JsScannedTrack): UpsertTrack => {
track: track.track,
artists: parseArtists(track.artist ?? ""),
album: parseAlbum(track.album ?? ""),
genres: parseGenres(track.genre),
duration: toMs(track.duration),
cover: toCacheUrl(track.cover),
codec: track.codec,
Expand Down Expand Up @@ -110,6 +112,7 @@ const syncCueTracks = async (
track: cueTrack.track,
artists: cueTrack.artists,
album: cueTrack.album,
genres: cueTrack.genres,
duration: cueTrack.duration,
cover: audio.cover,
codec: audio.quality?.codec,
Expand Down Expand Up @@ -178,11 +181,18 @@ export const startScan = (dirs: string[], incremental = true): void => {
return;
}

// 老库升级后曲目没有流派数据,增量扫描会因 mtime/size 未变化而整体跳过,这里强制全量回填一次
let useIncremental = incremental;
if (useIncremental && hasTracksMissingGenres()) {
useIncremental = false;
libraryLog.info("检测到曲目缺少流派数据,本次改为全量扫描以回填");
}

scanning = true;
libraryLog.info(`开始扫描 ${dirs.length} 个目录 (增量=${incremental})`);
libraryLog.info(`开始扫描 ${dirs.length} 个目录 (增量=${useIncremental})`);

// 增量扫描时传入已有文件记录,Rust 端会比对 mtime/size 跳过未变化的文件
const incrementalData = incremental ? getFileRecords() : undefined;
const incrementalData = useIncremental ? getFileRecords() : undefined;

const engine = getEngine();
engine.scanDirs(
Expand Down
Loading