From b4bfbd3ef6d40e725109273fd62d42a102a78fa7 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Wed, 12 Nov 2025 02:45:07 +1100 Subject: [PATCH] chore: update dependencies and add patch-package for better-sqlite3 feat: implement just-split category filter and enhance metadata parsing fix: improve type imports and variable handling in services --- package.json | 38 ++++++------ patches/better-sqlite3+12.4.1.patch | 13 +++++ src/main/services/LibraryService.ts | 51 +++++++++++----- src/main/services/SearchService.ts | 8 +-- src/main/services/TagService.ts | 4 +- src/renderer/src/App.tsx | 6 +- .../src/components/CategorySidebar.tsx | 17 +++++- src/renderer/src/stores/LibraryStore.ts | 58 +++++++++++++------ 8 files changed, 133 insertions(+), 62 deletions(-) create mode 100644 patches/better-sqlite3+12.4.1.patch diff --git a/package.json b/package.json index 91ba45a..8579f68 100644 --- a/package.json +++ b/package.json @@ -16,32 +16,34 @@ "start": "cross-env NODE_ENV=production electron ./dist/main/main/index.js", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", - "dist:win": "npm run build && electron-builder --win" + "dist:win": "npm run build && electron-builder --win", + "postinstall": "patch-package" }, "dependencies": { - "better-sqlite3": "^11.0.0", - "csv-parse": "^5.5.5", - "fast-glob": "^3.3.2", - "fuse.js": "^6.6.2", - "music-metadata": "^10.5.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "standardized-audio-context": "^25.3.12", + "better-sqlite3": "^12.4.1", + "csv-parse": "^6.1.0", + "fast-glob": "^3.3.3", + "fuse.js": "^7.1.0", + "music-metadata": "^11.10.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "standardized-audio-context": "^25.3.77", "wavefile": "^11.0.0" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.8", - "@types/node": "^20.12.7", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.3", - "concurrently": "^8.2.2", - "cross-env": "^7.0.3", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", "electron": "^39.1.2", "electron-builder": "^26.0.12", + "patch-package": "^8.0.1", "tree-kill": "^1.2.2", - "tsx": "^4.15.7", - "typescript": "^5.3.3", + "tsx": "^4.20.6", + "typescript": "^5.9.3", "vite": "^7.2.2" }, "build": { diff --git a/patches/better-sqlite3+12.4.1.patch b/patches/better-sqlite3+12.4.1.patch new file mode 100644 index 0000000..9b49892 --- /dev/null +++ b/patches/better-sqlite3+12.4.1.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/better-sqlite3/src/better_sqlite3.cpp b/node_modules/better-sqlite3/src/better_sqlite3.cpp +index 024bb4f..28a4d55 100644 +--- a/node_modules/better-sqlite3/src/better_sqlite3.cpp ++++ b/node_modules/better-sqlite3/src/better_sqlite3.cpp +@@ -46,7 +46,7 @@ class Backup; + #include "objects/statement-iterator.cpp" + + NODE_MODULE_INIT(/* exports, context */) { +- v8::Isolate* isolate = context->GetIsolate(); ++ v8::Isolate* isolate = v8::Isolate::GetCurrent(); + v8::HandleScope scope(isolate); + Addon::ConfigureURI(); + diff --git a/src/main/services/LibraryService.ts b/src/main/services/LibraryService.ts index c820796..5ce0d42 100644 --- a/src/main/services/LibraryService.ts +++ b/src/main/services/LibraryService.ts @@ -12,6 +12,15 @@ import { SearchService } from './SearchService'; import { WaveFile } from 'wavefile'; import { OrganizationService } from './OrganizationService'; +type MusicMetadataParser = (path: string, options?: { duration?: boolean }) => Promise<{ + format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null }; + common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown }; +}>; + +type MusicMetadataNamespace = Partial<{ parseFile: MusicMetadataParser }> & { + default?: Partial<{ parseFile: MusicMetadataParser }>; +}; + interface CsvCategoryRow { Category: string; SubCategory: string; @@ -32,6 +41,7 @@ export class LibraryService { private metadataSuggestionCache: { authors: Set } | null = null; private readonly waveformPreviewCache = new Map(); private offlineAudioContextCtor: (new (channelCount: number, length: number, sampleRate: number) => any) | null = null; + private musicMetadataParseFile: MusicMetadataParser | null = null; public constructor( private readonly database: DatabaseService, private readonly settings: SettingsService, @@ -1342,6 +1352,29 @@ export class LibraryService { return value.replace(/\\/g, '/'); } + /** + * Resolves and caches the music-metadata parseFile implementation. + */ + private async resolveMusicMetadataParser(): Promise { + if (this.musicMetadataParseFile) { + return this.musicMetadataParseFile; + } + + const namespace = (await import('music-metadata')) as MusicMetadataNamespace; + const candidate = typeof namespace.parseFile === 'function' + ? namespace.parseFile + : namespace.default && typeof namespace.default.parseFile === 'function' + ? namespace.default.parseFile + : null; + + if (!candidate) { + throw new Error('music-metadata parseFile not found'); + } + + this.musicMetadataParseFile = candidate; + return candidate; + } + /** * Extracts metadata from the WAV container, falling back to defaults when parsing fails. */ @@ -1353,22 +1386,8 @@ export class LibraryService { categories: string[]; }> { try { - const mmImport = await import('music-metadata'); - const mm = (mmImport as { default?: unknown }).default ?? mmImport; - - const loadMusicMetadata = typeof mm === 'object' && mm !== null && 'loadMusicMetadata' in mm - ? (mm as { loadMusicMetadata: () => Promise<{ parseFile: (path: string, opts?: { duration?: boolean }) => Promise<{ - format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null }; - common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown }; - }> }> }).loadMusicMetadata - : null; - - if (!loadMusicMetadata) { - throw new Error('music-metadata loadMusicMetadata not found'); - } - - const musicMetadata = await loadMusicMetadata(); - const metadata = await musicMetadata.parseFile(filePath, { duration: true }); + const parseFile = await this.resolveMusicMetadataParser(); + const metadata = await parseFile(filePath, { duration: true }); const infoTags = this.tagService.readInfoTags(filePath); const splitInfoValues = (input: string | undefined): string[] => input diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts index 1d5cdf7..4eb0288 100644 --- a/src/main/services/SearchService.ts +++ b/src/main/services/SearchService.ts @@ -1,4 +1,4 @@ -import Fuse from 'fuse.js'; +import Fuse, { type FuseResult, type IFuseOptions } from 'fuse.js'; import { AudioFileSummary } from '../../shared/models'; import { DatabaseService } from './DatabaseService'; import { TagService } from './TagService'; @@ -76,7 +76,7 @@ export class SearchService { return fuseInstance .search(remainingQuery) - .map((result: Fuse.FuseResult) => result.item) + .map((result: FuseResult) => result.item) .filter((file) => (filters.length === 0 ? true : this.matchesAdvancedFilters(file, filters))); } @@ -103,7 +103,7 @@ export class SearchService { /** * Provides the standard Fuse configuration used by the service. */ - private createFuseOptions(): Fuse.IFuseOptions { + private createFuseOptions(): IFuseOptions { return { includeScore: true, threshold: 0.35, @@ -114,7 +114,7 @@ export class SearchService { { name: 'tags', weight: 0.2 }, { name: 'categories', weight: 0.1 } ] - } satisfies Fuse.IFuseOptions; + } satisfies IFuseOptions; } /** diff --git a/src/main/services/TagService.ts b/src/main/services/TagService.ts index d81a342..853e606 100644 --- a/src/main/services/TagService.ts +++ b/src/main/services/TagService.ts @@ -163,8 +163,8 @@ export class TagService { .filter((entry) => entry.length > 0); const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null; - const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null; - const primaryCategory = categoryValuesList.at(0) ?? null; + const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null; + const primaryCategory = categoryValuesList.length > 0 ? categoryValuesList[0] : null; const trimmedTitle = metadata.title?.toString().trim(); const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null; const trimmedAuthor = metadata.author?.toString().trim(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c380a4d..9e82964 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type JSX } from 'react'; import FileList from './components/FileList'; import FileDetailPanel from './components/FileDetailPanel'; import MultiFileEditor from './components/MultiFileEditor'; @@ -274,8 +274,7 @@ function App(): JSX.Element { setActiveTab('listen'); }; - const handleEditModeSplitComplete = async (created: AudioFileSummary[]) => { - await libraryStore.rescan(); + const handleEditModeSplitComplete = (created: AudioFileSummary[]) => { setActiveTab('listen'); if (created.length > 0) { libraryStore.selectFile(created[0].id); @@ -294,6 +293,7 @@ function App(): JSX.Element { categories={library.categories} files={library.files} activeFilter={library.categoryFilter} + justSplitIds={library.justSplitFileIds} onSelect={handleCategorySelect} onDropFiles={handleDropFilesToCategory} /> diff --git a/src/renderer/src/components/CategorySidebar.tsx b/src/renderer/src/components/CategorySidebar.tsx index 4cbb947..5ddd2a0 100644 --- a/src/renderer/src/components/CategorySidebar.tsx +++ b/src/renderer/src/components/CategorySidebar.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import type { AudioFileSummary, CategoryRecord } from '../../../shared/models'; -import { CATEGORY_FILTER_UNTAGGED, type CategoryFilterValue } from '../stores/LibraryStore'; +import { CATEGORY_FILTER_JUST_SPLIT, CATEGORY_FILTER_UNTAGGED, type CategoryFilterValue } from '../stores/LibraryStore'; import { buildCategorySwatch, createCategoryStyleVars, @@ -11,6 +11,8 @@ export interface CategorySidebarProps { categories: CategoryRecord[]; files: AudioFileSummary[]; activeFilter: CategoryFilterValue; + /** Identifiers of files created during the most recent split action. */ + justSplitIds: number[]; onSelect(filter: CategoryFilterValue): void; onDropFiles?(fileIds: number[], categoryId: string): void; } @@ -37,7 +39,7 @@ function deriveCounts(files: AudioFileSummary[]): { }; } -export function CategorySidebar({ categories, files, activeFilter, onSelect, onDropFiles }: CategorySidebarProps): JSX.Element { +export function CategorySidebar({ categories, files, activeFilter, justSplitIds, onSelect, onDropFiles }: CategorySidebarProps): JSX.Element { const { total, untagged, categoryCounts } = deriveCounts(files); const swatchMap = useMemo(() => { const map = new Map>(); @@ -103,6 +105,17 @@ export function CategorySidebar({ categories, files, activeFilter, onSelect, onD TO TAG {untagged} + {justSplitIds.length > 0 ? ( + + ) : null} {sortedGroups.map(([groupName, records]) => { const sortedRecords = records diff --git a/src/renderer/src/stores/LibraryStore.ts b/src/renderer/src/stores/LibraryStore.ts index 54aa5b3..d9412d8 100644 --- a/src/renderer/src/stores/LibraryStore.ts +++ b/src/renderer/src/stores/LibraryStore.ts @@ -10,7 +10,9 @@ import type { type ChangeListener = () => void; export const CATEGORY_FILTER_UNTAGGED = 'untagged' as const; -export type CategoryFilterValue = string | typeof CATEGORY_FILTER_UNTAGGED | null; +/** Filter key that exposes files generated by the most recent split action. */ +export const CATEGORY_FILTER_JUST_SPLIT = 'just-split' as const; +export type CategoryFilterValue = string | typeof CATEGORY_FILTER_UNTAGGED | typeof CATEGORY_FILTER_JUST_SPLIT | null; export interface LibrarySnapshot { initialized: boolean; @@ -25,6 +27,8 @@ export interface LibrarySnapshot { categoryFilter: CategoryFilterValue; lastScan: LibraryScanSummary | null; metadataSuggestionsVersion: number; + /** Files created by the most recent split action during this app session. */ + justSplitFileIds: number[]; } /** @@ -43,7 +47,8 @@ export class LibraryStore extends EventTarget { searchQuery: '', categoryFilter: null, lastScan: null, - metadataSuggestionsVersion: 0 + metadataSuggestionsVersion: 0, + justSplitFileIds: [] }; /** @@ -70,7 +75,8 @@ export class LibraryStore extends EventTarget { searchQuery: '', categoryFilter: null, lastScan: null, - metadataSuggestionsVersion: nextVersion + metadataSuggestionsVersion: nextVersion, + justSplitFileIds: [] }; this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null); } @@ -263,7 +269,10 @@ export class LibraryStore extends EventTarget { }; let visible: AudioFileSummary[]; - if (filter === CATEGORY_FILTER_UNTAGGED) { + if (filter === CATEGORY_FILTER_JUST_SPLIT) { + const justSplitIds = new Set(this.snapshot.justSplitFileIds); + visible = files.filter((file) => justSplitIds.has(file.id)); + } else if (filter === CATEGORY_FILTER_UNTAGGED) { visible = files.filter((file) => file.categories.length === 0); } else if (filter) { visible = files.filter((file) => file.categories.includes(filter)); @@ -275,7 +284,8 @@ export class LibraryStore extends EventTarget { ...this.snapshot, visibleFiles: visible, selectedFileId, - focusedFile + focusedFile, + justSplitFileIds: this.snapshot.justSplitFileIds }; this.emitChange(); } @@ -398,21 +408,29 @@ export class LibraryStore extends EventTarget { public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise { const created = await window.api.splitFile(fileId, segments); const files = await window.api.listAudioFiles(); - const { categoryFilter } = this.snapshot; - let visible: AudioFileSummary[]; - if (categoryFilter === CATEGORY_FILTER_UNTAGGED) { - visible = files.filter((file) => file.categories.length === 0); - } else if (categoryFilter) { - visible = files.filter((file) => file.categories.includes(categoryFilter)); - } else { - visible = files.slice(); + const createdIds = created.map((record) => record.id); + const fileLookup = new Map(files.map((record) => [record.id, record] as const)); + // Prefer fresh copies from the library list so downstream consumers see current metadata. + const justSplitRecords: AudioFileSummary[] = createdIds + .map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null) + .filter((record): record is AudioFileSummary => record !== null); + + const primaryId = justSplitRecords.at(0)?.id ?? null; + const nextSelection = new Set(justSplitRecords.map((record) => record.id)); + if (primaryId !== null) { + nextSelection.add(primaryId); } this.snapshot = { ...this.snapshot, files, - visibleFiles: visible, - metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1 + visibleFiles: justSplitRecords, + metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1, + categoryFilter: CATEGORY_FILTER_JUST_SPLIT, + justSplitFileIds: justSplitRecords.map((record) => record.id), + selectedFileId: primaryId, + selectedFileIds: nextSelection, + focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords.at(0) ?? null : null }; this.emitChange(); return created; @@ -462,8 +480,13 @@ export class LibraryStore extends EventTarget { private refreshVisibleFiles(preferredId?: number | null, emit = true): void { const { files, categoryFilter } = this.snapshot; + const availableIds = new Set(files.map((file) => file.id)); + const justSplitIds = this.snapshot.justSplitFileIds.filter((id) => availableIds.has(id)); let visible: AudioFileSummary[]; - if (categoryFilter === CATEGORY_FILTER_UNTAGGED) { + if (categoryFilter === CATEGORY_FILTER_JUST_SPLIT) { + const justSplitSet = new Set(justSplitIds); + visible = files.filter((file) => justSplitSet.has(file.id)); + } else if (categoryFilter === CATEGORY_FILTER_UNTAGGED) { visible = files.filter((file) => file.categories.length === 0); } else if (categoryFilter) { visible = files.filter((file) => file.categories.includes(categoryFilter)); @@ -500,7 +523,8 @@ export class LibraryStore extends EventTarget { visibleFiles: visible, selectedFileId: nextSelected, selectedFileIds: nextSelectionSet, - focusedFile: nextFocused + focusedFile: nextFocused, + justSplitFileIds: justSplitIds }; if (emit) {