diff --git a/components.d.ts b/components.d.ts index 2063b08a9..4afe8648e 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index 4747ddfa9..a8bb2c129 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -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, @@ -241,6 +242,9 @@ export { deleteTracksByDir, getAlbumList, getArtistList, + getGenreList, + getGenreTracks, + hasTracksMissingGenres, getAlbumTracks, getArtistTracks, getTracksByIds, diff --git a/electron/main/database/migration.ts b/electron/main/database/migration.ts index 728d2621c..bcf8afccc 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 = 4; +const SCHEMA_VERSION = 6; type TableInfoRow = { name: string }; @@ -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")) { diff --git a/electron/main/database/queries.ts b/electron/main/database/queries.ts index 1dce1e869..b9e0dd80c 100644 --- a/electron/main/database/queries.ts +++ b/electron/main/database/queries.ts @@ -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"; @@ -16,6 +16,7 @@ interface TrackRow { track?: number; artists: string; album: string | null; + genres: string | null; duration: number; cover: string | null; codec: string | null; @@ -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, @@ -131,6 +133,8 @@ export interface UpsertTrack { track?: number; artists: Artist[]; album?: Album; + /** 流派列表(已解析拆分) */ + genres?: string[]; duration: number; cover?: string; codec?: string; @@ -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(() => { @@ -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, @@ -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); }; @@ -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() @@ -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 @@ -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 []; diff --git a/electron/main/ipc/library.ts b/electron/main/ipc/library.ts index d10b1cd56..2deea07fa 100644 --- a/electron/main/ipc/library.ts +++ b/electron/main/ipc/library.ts @@ -9,6 +9,8 @@ import { deleteTracksByPaths, getAlbumList, getArtistList, + getGenreList, + getGenreTracks, getAlbumTracks, getArtistTracks, getTracksByIds, @@ -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 { diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 543b4e859..cc12e301f 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -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"; @@ -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, diff --git a/electron/main/services/cue.test.ts b/electron/main/services/cue.test.ts index 33e4393c5..13b7b0baa 100644 --- a/electron/main/services/cue.test.ts +++ b/electron/main/services/cue.test.ts @@ -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"); }); diff --git a/electron/main/services/cue.ts b/electron/main/services/cue.ts index f9dbc43f9..c96121ed7 100644 --- a/electron/main/services/cue.ts +++ b/electron/main/services/cue.ts @@ -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; @@ -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[]; } @@ -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; @@ -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, diff --git a/electron/main/services/scanner.ts b/electron/main/services/scanner.ts index 0d535a952..4b80786ce 100644 --- a/electron/main/services/scanner.ts +++ b/electron/main/services/scanner.ts @@ -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"; @@ -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, @@ -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, @@ -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( diff --git a/electron/main/utils/metadata.test.ts b/electron/main/utils/metadata.test.ts new file mode 100644 index 000000000..9398256a0 --- /dev/null +++ b/electron/main/utils/metadata.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseGenres, formatGenres } from "./metadata"; + +describe("parseGenres", () => { + it("把无空格的斜杠视为流派名称的一部分", () => { + assert.deepEqual(parseGenres("Singer/Songwriter"), ["Singer/Songwriter"]); + assert.deepEqual(parseGenres("R&B/Soul"), ["R&B/Soul"]); + }); + + it("空格不是分隔符", () => { + assert.deepEqual(parseGenres("Pop Rock"), ["Pop Rock"]); + }); + + it("把无空格的 & 视为流派名称的一部分", () => { + assert.deepEqual(parseGenres("R&B"), ["R&B"]); + assert.deepEqual(parseGenres("R & B"), ["R & B"]); + }); + + it("按逗号、分号、斜杠与 & 拆分多个流派", () => { + assert.deepEqual(parseGenres("Pop, Rock"), ["Pop", "Rock"]); + assert.deepEqual(parseGenres("Pop & Rock"), ["Pop", "Rock"]); + assert.deepEqual(parseGenres("Pop; Rock"), ["Pop", "Rock"]); + assert.deepEqual(parseGenres("Pop // Rock"), ["Pop", "Rock"]); + assert.deepEqual(parseGenres("Pop / Rock"), ["Pop", "Rock"]); + assert.deepEqual(parseGenres("Pop//Rock"), ["Pop", "Rock"]); + }); + + it("保护已知的复合流派", () => { + assert.deepEqual(parseGenres("Rock & Roll, Pop"), ["Rock & Roll", "Pop"]); + assert.deepEqual(parseGenres("R&B/Soul; Hip Hop/Rap"), ["R&B/Soul", "Hip Hop/Rap"]); + assert.deepEqual(parseGenres("Singer / Songwriter & Folk"), ["Singer / Songwriter", "Folk"]); + }); + + it("清理空白、引号与 ID3v1 数字流派", () => { + assert.deepEqual(parseGenres(' "Pop" , Rock '), ["Pop", "Rock"]); + assert.deepEqual(parseGenres("(17)Rock"), ["Rock"]); + assert.deepEqual(parseGenres("(17)"), []); + assert.deepEqual(parseGenres("Unknown"), []); + }); + + it("忽略大小写去重并保留首次出现的写法", () => { + assert.deepEqual(parseGenres("Pop, POP, pop"), ["Pop"]); + }); + + it("空值返回空数组", () => { + assert.deepEqual(parseGenres(""), []); + assert.deepEqual(parseGenres(undefined), []); + }); +}); + +describe("formatGenres", () => { + it("按分隔符拼接流派", () => { + assert.equal(formatGenres(["Pop", "Rock"]), "Pop / Rock"); + assert.equal(formatGenres([]), ""); + assert.equal(formatGenres(undefined), ""); + }); +}); diff --git a/electron/main/utils/metadata.ts b/electron/main/utils/metadata.ts index a40f43767..a81322caf 100644 --- a/electron/main/utils/metadata.ts +++ b/electron/main/utils/metadata.ts @@ -46,3 +46,121 @@ export const parseAlbum = (raw: string): Album | undefined => { const name = raw.trim(); return name ? { name } : undefined; }; + +/** + * 已知包含分隔符字符的复合流派,拆分前先整体保护 + * 例如 R&B、Rock & Roll 中的 & 属于流派名称本身,不能当作分隔符 + */ +const COMPOUND_GENRES = [ + "r&b", + "r&b/soul", + "rock & roll", + "rock'n'roll", + "rock n roll", + "rhythm & blues", + "drum & bass", + "drum'n'bass", + "singer/songwriter", + "hip hop/rap", + "hip-hop/rap", + "stage & screen", + "brass & military", + "country & western", + "funk / soul", + "sound & effects", +]; + +/** + * 复合流派匹配规则:分隔符两侧的空白可有可无 + * "R & B" 与 "R&B"、"Singer / Songwriter" 与 "Singer/Songwriter" 视为同一个流派 + */ +const COMPOUND_PATTERNS = COMPOUND_GENRES.map((genre) => ({ + canonical: genre, + pattern: new RegExp( + genre + .split(/\s*([&/'])\s*/) + .map((part) => + /^[&/']$/.test(part) ? `\\s*\\${part}\\s*` : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ) + .join(""), + "gi", + ), +})); + +/** + * 流派分隔符: + * - 逗号、分号、中文顿号/逗号、竖线:始终视为分隔符 + * - 双斜杠 `//`:始终视为分隔符 + * - 单斜杠:仅在两侧有空白时才是分隔符(`Singer/Songwriter`、`R&B/Soul` 是同一个流派) + * - `&`:仅在两侧有空白时才是分隔符(`R&B` 是同一个流派) + */ +const GENRE_SEPARATOR = /\s*[,;、,|]\s*|\s*\/{2,}\s*|\s+\/\s+|\s+&\s+|\0/; + +/** 复合流派占位符使用私有区字符,避免与真实标签内容冲突 */ +const PLACEHOLDER_MARK = "\uE000"; + +/** 占位符匹配规则 */ +const PLACEHOLDER_PATTERN = new RegExp(`${PLACEHOLDER_MARK}(\\d+)${PLACEHOLDER_MARK}`, "g"); + +/** 去掉两端的引号、括号等包裹字符与多余空白 */ +const cleanGenre = (value: string): string => + value + .replace(/\s+/g, " ") + .trim() + // ID3v1 数字流派前缀,如 "(17)Rock" + .replace(/^\((\d+)\)\s*/, "") + .replace(/^["'“”「『([]+|["'“”」』)\]]+$/g, "") + .trim(); + +/** + * 解析流派标签字符串 + * + * 流派标签写法混乱,这里遵循以下约定: + * 1. `Singer/Songwriter`、`R&B/Soul`:无空格的斜杠属于名称本身,识别为单个流派 + * 2. `Pop Rock`:空格不是分隔符,识别为单个流派 + * 3. `R&B`:无空格的 `&` 属于名称本身,识别为单个流派 + * 4. `Pop, Rock`、`Pop; Rock`、`Pop // Rock`、`Pop & Rock`、`Pop / Rock`:识别为两个流派 + * 5. 已知复合流派(如 `Rock & Roll`)整体保护,不会被拆开 + * + * @param raw - 原始流派标签文本 + * @returns 流派名称数组,已去重(忽略大小写)并保留首次出现的写法 + */ +export const parseGenres = (raw?: string | null): string[] => { + if (!raw) return []; + // 复合流派先替换为占位符,避免被分隔符规则拆开 + const placeholders: string[] = []; + let text = raw; + for (const { pattern } of COMPOUND_PATTERNS) { + text = text.replace(pattern, (matched) => { + placeholders.push(matched); + return `${PLACEHOLDER_MARK}${placeholders.length - 1}${PLACEHOLDER_MARK}`; + }); + } + + const result: string[] = []; + const seen = new Set(); + for (const part of text.split(GENRE_SEPARATOR)) { + // 还原占位符 + const restored = part.replace(PLACEHOLDER_PATTERN, (_match, index: string) => + cleanGenre(placeholders[Number(index)] ?? ""), + ); + const name = cleanGenre(restored); + // 过滤空值与 ID3v1 未知流派 + if (!name || /^\(?\d+\)?$/.test(name)) continue; + if (/^(unknown|other|未知|其他)$/i.test(name)) continue; + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(name); + } + return result; +}; + +/** + * 将流派数组格式化为展示字符串 + * @param genres - 流派数组 + * @param separator - 分隔符,默认 " / " + * @returns 拼接后的字符串,空数组返回空字符串 + */ +export const formatGenres = (genres?: string[], separator = " / "): string => + (genres ?? []).filter(Boolean).join(separator); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index ebd3c975b..3c1d87534 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -233,6 +233,10 @@ const api = { getAlbums: () => ipcRenderer.invoke("library:getAlbums"), // 获取歌手聚合列表 getArtists: () => ipcRenderer.invoke("library:getArtists"), + // 获取流派聚合列表 + getGenres: () => ipcRenderer.invoke("library:getGenres"), + // 获取某流派下的全部曲目 + getGenreTracks: (genreName: string) => ipcRenderer.invoke("library:getGenreTracks", genreName), // 获取某专辑下的全部曲目 getAlbumTracks: (albumName: string) => ipcRenderer.invoke("library:getAlbumTracks", albumName), // 获取某歌手的全部曲目 diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index b43b0b7ca..c474b7819 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -232,6 +232,8 @@ export interface JsMusicMetadata { album?: string /** 注释/副标题 */ comment?: string + /** 流派原始标签文本(可能包含多个流派,由 JS 侧解析拆分) */ + genre?: string /** 时长(秒) */ duration: number /** 播放采样率(重采样后) */ @@ -318,6 +320,8 @@ export interface JsScannedTrack { album?: string /** 音轨编号 */ track?: number + /** 流派原始标签文本(可能包含多个流派,由 JS 侧解析拆分) */ + genre?: string /** 时长(秒) */ duration: number codec: string diff --git a/native/audio-engine/src/bindings/player/load.rs b/native/audio-engine/src/bindings/player/load.rs index 2662fb5db..d5173f15e 100644 --- a/native/audio-engine/src/bindings/player/load.rs +++ b/native/audio-engine/src/bindings/player/load.rs @@ -221,6 +221,7 @@ impl AudioPlayer { title: meta.title, artist: meta.artist, album: meta.album, + genre: meta.genre, comment: meta.comment, duration: meta.duration_secs, sample_rate: meta.sample_rate, diff --git a/native/audio-engine/src/bindings/player/types.rs b/native/audio-engine/src/bindings/player/types.rs index 9f5c3c742..d1cc7716a 100644 --- a/native/audio-engine/src/bindings/player/types.rs +++ b/native/audio-engine/src/bindings/player/types.rs @@ -17,6 +17,8 @@ pub struct JsMusicMetadata { pub title: Option, pub artist: Option, pub album: Option, + /// 流派原始标签文本(可能包含多个流派,由 JS 侧解析拆分) + pub genre: Option, /// 注释/副标题 pub comment: Option, /// 时长(秒) diff --git a/native/audio-engine/src/bindings/scanner.rs b/native/audio-engine/src/bindings/scanner.rs index ef60e70dc..1816973b5 100644 --- a/native/audio-engine/src/bindings/scanner.rs +++ b/native/audio-engine/src/bindings/scanner.rs @@ -30,6 +30,8 @@ pub struct JsScannedTrack { pub album: Option, /// 音轨编号 pub track: Option, + /// 流派原始标签文本(可能包含多个流派,由 JS 侧解析拆分) + pub genre: Option, /// 时长(秒) pub duration: f64, pub codec: String, @@ -55,6 +57,7 @@ impl From for JsScannedTrack { artist: track.artist, album: track.album, track: track.track, + genre: track.genre, duration: track.duration, codec: track.codec, sample_rate: track.sample_rate, diff --git a/native/audio-engine/src/decoder/mod.rs b/native/audio-engine/src/decoder/mod.rs index 4445edf6f..e7ec69cb5 100644 --- a/native/audio-engine/src/decoder/mod.rs +++ b/native/audio-engine/src/decoder/mod.rs @@ -133,6 +133,7 @@ pub fn prepare_decode( title: tags.title, artist: tags.artist, album: tags.album, + genre: tags.genre, comment: tags.comment, duration_secs, sample_rate: stream_info.sample_rate, diff --git a/native/audio-engine/src/metadata/mod.rs b/native/audio-engine/src/metadata/mod.rs index 035a4e8ad..be5e557ee 100644 --- a/native/audio-engine/src/metadata/mod.rs +++ b/native/audio-engine/src/metadata/mod.rs @@ -22,6 +22,8 @@ pub struct AudioMetadata { pub album: Option, /// 注释/副标题 pub comment: Option, + /// 流派原始标签文本(可能包含多个流派,交由 JS 侧解析) + pub genre: Option, pub duration_secs: f64, /// 播放采样率(重采样后,用于音频输出) pub sample_rate: u32, @@ -60,6 +62,8 @@ pub struct Tags { pub album: Option, pub track: Option, pub comment: Option, + /// 流派原始标签文本 + pub genre: Option, } /// 把 ffmpeg_audio 的 SourceAudioInfo 转成内部 StreamInfo @@ -81,12 +85,18 @@ pub fn extract_tags(dict: &HashMap) -> Tags { let album = dict_get(dict, "album").map(ToString::to_string); let track = dict_get(dict, "track").and_then(|s| s.parse().ok()); let comment = dict_get(dict, "comment").map(ToString::to_string); + // 不同容器的流派键名不一致:ID3 为 genre/TCON,Vorbis 为 GENRE,部分工具写 style + let genre = dict_get(dict, "genre") + .or_else(|| dict_get(dict, "TCON")) + .or_else(|| dict_get(dict, "style")) + .map(ToString::to_string); Tags { title, artist, album, track, comment, + genre, } } diff --git a/native/audio-engine/src/metadata/tests/mod.rs b/native/audio-engine/src/metadata/tests/mod.rs index bd7856e60..753fb4b9e 100644 --- a/native/audio-engine/src/metadata/tests/mod.rs +++ b/native/audio-engine/src/metadata/tests/mod.rs @@ -6,12 +6,14 @@ fn common_tags_are_matched_case_insensitively() { ("TITLE".to_string(), "Track".to_string()), ("Album_Artist".to_string(), "Artist".to_string()), ("TRACK".to_string(), "7".to_string()), + ("Genre".to_string(), "Pop Rock".to_string()), ]); let tags = extract_tags(&dict); assert_eq!(tags.title.as_deref(), Some("Track")); assert_eq!(tags.artist.as_deref(), Some("Artist")); assert_eq!(tags.track, Some(7)); + assert_eq!(tags.genre.as_deref(), Some("Pop Rock")); } #[test] diff --git a/native/audio-engine/src/scanner.rs b/native/audio-engine/src/scanner.rs index 0fb58c315..e0bda37ef 100644 --- a/native/audio-engine/src/scanner.rs +++ b/native/audio-engine/src/scanner.rs @@ -38,6 +38,8 @@ pub struct ScannedTrack { pub artist: Option, pub album: Option, pub track: Option, + /// 流派原始标签文本(可能包含多个流派) + pub genre: Option, pub duration: f64, pub codec: String, pub sample_rate: u32, @@ -138,6 +140,7 @@ pub(crate) fn probe_fast(path: &str, cover_cache_dir: Option<&str>) -> Option Promise>; /** 获取歌手聚合列表 */ getArtists: () => Promise>; + /** 获取流派聚合列表 */ + getGenres: () => Promise>; /** 获取某专辑下的全部曲目 */ getAlbumTracks: (albumName: string) => Promise>; /** 获取某歌手的全部曲目 */ getArtistTracks: (artistName: string) => Promise>; + /** 获取某流派下的全部曲目 */ + getGenreTracks: (genreName: string) => Promise>; /** 按 ID 批量获取曲目 */ getTracksByIds: (ids: string[]) => Promise>; /** 搜索曲目 */ diff --git a/shared/types/player.ts b/shared/types/player.ts index 42d8137a4..36ae1d3b5 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -14,7 +14,8 @@ export type ShuffleMode = "off" | "on"; export type TrackSource = "local" | "streaming" | Platform; /** 播放来源类型 */ -export type PlaybackOriginType = "track" | "playlist" | "album" | "artist" | "radio" | "page"; +export type PlaybackOriginType = + "track" | "playlist" | "album" | "artist" | "genre" | "radio" | "page"; /** 本次播放的来源上下文 */ export interface PlaybackContext { @@ -112,6 +113,8 @@ export interface Track { artists: Artist[]; /** 专辑 */ album?: Album; + /** 流派列表(已按分隔符解析拆分) */ + genres?: string[]; /** 曲目编号 */ track?: number; /** 时长(毫秒) */ @@ -156,6 +159,8 @@ export interface MediaInfo { artists?: Artist[]; /** 专辑 */ album?: Album; + /** 流派列表(已按分隔符解析拆分) */ + genres?: string[]; /** 时长(毫秒) */ duration: number; /** 缩略封面(cache:// URL 或 base64) */ diff --git a/shared/types/stats.ts b/shared/types/stats.ts index fe18ce278..38d8ce166 100644 --- a/shared/types/stats.ts +++ b/shared/types/stats.ts @@ -60,6 +60,8 @@ export interface LibraryStats { albumCount: number; /** 歌手数量(按歌手名去重) */ artistCount: number; + /** 流派数量(按流派名去重,忽略大小写) */ + genreCount: number; /** 总时长(毫秒) */ totalDurationMs: number; /** 总文件大小(字节) */ diff --git a/src/components/layout/SideBar.vue b/src/components/layout/SideBar.vue index c6f123093..ca8a8516e 100644 --- a/src/components/layout/SideBar.vue +++ b/src/components/layout/SideBar.vue @@ -282,6 +282,8 @@ const activeKey = computed(() => { if (route.path.startsWith("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/artist/streaming/")) return "/streaming"; // 专辑详情页归属专辑列表 if (route.path.startsWith("/collection/local/album/")) return "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/albums/local"; + // 流派详情页归属流派列表 + if (route.path.startsWith("/collection/local/genre/")) return "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/genres/local"; // 音乐库子页面 if (route.path.startsWith("/collection/") && !route.path.includes("/playlist/")) return "/library"; diff --git a/src/components/layout/sidebarNav.ts b/src/components/layout/sidebarNav.ts index 99993dcb6..6c5cbea14 100644 --- a/src/components/layout/sidebarNav.ts +++ b/src/components/layout/sidebarNav.ts @@ -3,6 +3,7 @@ import IconLucideHome from "~icons/lucide/home"; import IconLucideMusic from "~icons/lucide/music"; import IconLucideUser from "~icons/lucide/user"; import IconLucideDisc3 from "~icons/lucide/disc-3"; +import IconLucideGuitar from "~icons/lucide/guitar"; import IconLucideFolder from "~icons/lucide/folder"; import IconLucideChartPie from "~icons/lucide/chart-pie"; import IconLucideLibrary from "~icons/lucide/library"; @@ -28,6 +29,7 @@ const SIDEBAR_NAV_ENTRIES: SidebarNavEntry[] = [ { key: "/library", labelKey: "nav.library", icon: IconLucideMusic, hideable: true }, { key: "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/artists/local", labelKey: "artist.label", icon: IconLucideUser, hideable: true }, { key: "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/albums/local", labelKey: "album.label", icon: IconLucideDisc3, hideable: true }, + { key: "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/genres/local", labelKey: "genre.label", icon: IconLucideGuitar, hideable: true }, { key: "/folders", labelKey: "folder.label", icon: IconLucideFolder, hideable: true }, { key: "/stats", labelKey: "stats.label", icon: IconLucideChartPie, hideable: true }, { diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index cfd370179..e70a709b6 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -14,7 +14,7 @@ import { useFloatingPlayerBar } from "@/composables/useFloatingPlayerBar"; import { formatTime } from "@/utils/time"; import { formatFileSize } from "@/utils/format"; import { isLosslessQuality, getQualityLabel } from "@/utils/quality"; -import { navigateToAlbum, navigateToArtist } from "@/utils/navigate"; +import { navigateToAlbum, navigateToArtist, navigateToGenre } from "@/utils/navigate"; import type { SVirtualListExposed } from "@/components/ui/SVirtualList.vue"; import * as player from "@/core/player"; import IconArrowUpDown from "~icons/lucide/arrow-up-down"; @@ -41,6 +41,8 @@ const props = withDefaults( showIndex?: boolean; /** 显示专辑 */ showAlbum?: boolean; + /** 显示流派 */ + showGenre?: boolean; /** 显示时长 */ showDuration?: boolean; /** 显示文件大小 */ @@ -66,6 +68,7 @@ const props = withDefaults( searchQuery: "", showIndex: true, showAlbum: true, + showGenre: false, showDuration: true, showSize: false, enableSort: false, @@ -122,6 +125,19 @@ const goAlbum = (item: Track): void => { navigateToAlbum(item.album?.name, { source: item.source, albumId: item.album?.id }); }; +/** 有效流派列表 */ +const trackGenres = (item: Track): string[] => + (item.genres ?? []).map((genre) => genre.trim()).filter(Boolean); + +/** 流派是否可跳转:流派页仅聚合本地曲库 */ +const isGenreLinkable = (item: Track): boolean => item.source === "local"; + +/** 跳转到流派页 */ +const goGenre = (item: Track, genre: string): void => { + if (!isGenreLinkable(item)) return; + navigateToGenre(genre); +}; + /** 排序字段 */ const { sortField, sortOrder } = storeToRefs(status); @@ -157,7 +173,13 @@ const filteredItems = computed(() => { .map((artist) => (artist.name ?? "").toLowerCase()) .join(" "); const album = track.album?.name?.toLowerCase() ?? ""; - return title.includes(query) || artists.includes(query) || album.includes(query); + const genres = (track.genres ?? []).join(" ").toLowerCase(); + return ( + title.includes(query) || + artists.includes(query) || + album.includes(query) || + genres.includes(query) + ); }); }); @@ -502,6 +524,7 @@ defineExpose({
{{ t("songList.album") }}
+
{{ t("songList.genre") }}
{{ t("songList.actions") }}
{{ t("songList.duration") }} @@ -670,6 +693,28 @@ defineExpose({ {{ item.album?.name || t("collection.unknownAlbum") }}
+ +
+ + {{ t("genre.unknown") }} +
media.activeLyric?.format.toUpperCase() ?? "NO /** 专辑文本 */ const albumText = computed(() => displayTrack.value?.album?.name ?? ""); +/** 流派列表 */ +const genres = computed(() => + (displayTrack.value?.genres ?? []).map((name) => name.trim()).filter(Boolean), +); + +/** 生成流派详情页跳转目标(仅本地曲目按流派聚合) */ +const genreTarget = (genre: string): ResourceNavigationTarget => ({ + type: "genre", + source: displayTrack.value?.source, + name: genre, +}); + +/** 流派是否可跳转 */ +const isGenreLinkable = (genre: string): boolean => canNavigateToResource(genreTarget(genre)); + /** 当前实际加载歌曲的播放来源 */ const playbackSource = computed(() => media.playbackContext); const playbackSourceTarget = computed(() => { @@ -211,6 +226,26 @@ const alignItems = computed(() => { {{ albumText }}
+ +
+ + + + +
(() => { value: stats ? String(stats.artistCount) : "--", to: "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/artists/local", }, + { + key: "genres", + icon: IconLucideGuitar, + iconClass: "rotate-14 -scale-x-100", + value: stats ? String(stats.genreCount) : "--", + to: "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/genres/local", + }, duration ? { key: "totalDuration", @@ -101,7 +111,7 @@ const overviewCards = computed(() => {