mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 18:56:01 +10:00
Initial
This commit is contained in:
374
src/main/MainApp.ts
Normal file
374
src/main/MainApp.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, Menu } from 'electron';
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/ipc';
|
||||
import { TagUpdatePayload } from '../shared/models';
|
||||
import { DatabaseService } from './services/DatabaseService';
|
||||
import { LibraryService } from './services/LibraryService';
|
||||
import { SearchService } from './services/SearchService';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
import { TagService } from './services/TagService';
|
||||
|
||||
/**
|
||||
* Central application coordinator responsible for bootstrapping Electron and wiring IPC handlers.
|
||||
*/
|
||||
export class MainApp {
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private database: DatabaseService | null = null;
|
||||
private settingsService: SettingsService | null = null;
|
||||
private libraryService: LibraryService | null = null;
|
||||
private searchService: SearchService | null = null;
|
||||
|
||||
/**
|
||||
* Entry point called once Electron is ready.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
nativeTheme.themeSource = 'dark';
|
||||
const userData = app.getPath('userData');
|
||||
const dbPath = path.join(userData, 'audiosort', 'audiosort.db');
|
||||
|
||||
this.database = new DatabaseService(dbPath);
|
||||
this.database.initialize();
|
||||
|
||||
this.settingsService = new SettingsService(this.database);
|
||||
const tagService = new TagService(this.database);
|
||||
this.searchService = new SearchService(this.database, tagService);
|
||||
this.libraryService = new LibraryService(
|
||||
this.database,
|
||||
this.settingsService,
|
||||
tagService,
|
||||
this.searchService
|
||||
);
|
||||
|
||||
const catalogPath = this.resolveResourcePath('UCS.csv');
|
||||
await this.libraryService.ensureCategoriesLoaded(catalogPath);
|
||||
await this.libraryService.scanLibrary();
|
||||
|
||||
this.searchService.rebuildIndex();
|
||||
this.registerIpcHandlers();
|
||||
this.createMenu();
|
||||
this.createWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the application menu.
|
||||
*/
|
||||
private createMenu(): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac ? [{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' as const },
|
||||
{ type: 'separator' as const },
|
||||
{
|
||||
label: 'Settings',
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => this.mainWindow?.webContents.send('open-settings')
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'hide' as const },
|
||||
{ role: 'hideOthers' as const },
|
||||
{ role: 'unhide' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const }
|
||||
]
|
||||
}] : []),
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Rescan Library',
|
||||
accelerator: isMac ? 'Cmd+R' : 'Ctrl+R',
|
||||
click: () => this.mainWindow?.webContents.send('rescan-library')
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
...(!isMac ? [
|
||||
{
|
||||
label: 'Settings',
|
||||
accelerator: 'Ctrl+,',
|
||||
click: () => this.mainWindow?.webContents.send('open-settings')
|
||||
},
|
||||
{ type: 'separator' as const }
|
||||
] : []),
|
||||
...(isMac ? [{ role: 'close' as const }] : [{ role: 'quit' as const }])
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Find Duplicates',
|
||||
click: () => this.mainWindow?.webContents.send('find-duplicates')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' as const },
|
||||
{ role: 'forceReload' as const },
|
||||
{ role: 'toggleDevTools' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'resetZoom' as const },
|
||||
{ role: 'zoomIn' as const },
|
||||
{ role: 'zoomOut' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'togglefullscreen' as const }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully releases resources during application shutdown.
|
||||
*/
|
||||
public dispose(): void {
|
||||
ipcMain.removeHandler(IPC_CHANNELS.settingsGet);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.settingsSetLibrary);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectLibrary);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryScan);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryList);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryRename);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMove);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryOrganize);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryBuffer);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.searchQuery);
|
||||
this.database?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the renderer window when requested (for macOS style activate behaviour).
|
||||
*/
|
||||
public ensureWindow(): void {
|
||||
if (this.mainWindow) {
|
||||
this.mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
this.createWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the renderer window and loads the UI.
|
||||
*/
|
||||
private createWindow(): void {
|
||||
const preloadPath = this.resolvePreloadPath();
|
||||
this.mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
backgroundColor: '#111518',
|
||||
show: false,
|
||||
title: 'AudioSort',
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
this.mainWindow.on('ready-to-show', () => {
|
||||
this.mainWindow?.show();
|
||||
});
|
||||
|
||||
this.mainWindow.on('closed', () => {
|
||||
this.mainWindow = null;
|
||||
});
|
||||
|
||||
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
|
||||
?.VITE_DEV_SERVER_URL;
|
||||
if (devServerUrl) {
|
||||
this.mainWindow.loadURL(devServerUrl).catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- Logging is useful during development to diagnose boot issues.
|
||||
console.error('Failed to load renderer URL', error);
|
||||
});
|
||||
} else {
|
||||
const rendererIndex = this.resolveRendererIndex();
|
||||
this.mainWindow
|
||||
.loadFile(rendererIndex)
|
||||
.catch((error: unknown) => console.error('Failed to load renderer bundle', error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires IPC handlers that power the renderer bridge API.
|
||||
*/
|
||||
private registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.settingsGet, async () => this.requireSettings().getSettings());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.settingsSetLibrary, async (_event: IpcMainInvokeEvent, targetPath: string) => {
|
||||
const settings = this.requireSettings().updateLibraryPath(targetPath);
|
||||
await this.requireLibrary().scanLibrary();
|
||||
return settings;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => {
|
||||
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
||||
const targetWindow = this.mainWindow;
|
||||
const result = targetWindow
|
||||
? await dialog.showOpenDialog(targetWindow, options)
|
||||
: await dialog.showOpenDialog(options);
|
||||
return result.canceled ? null : result.filePaths[0] ?? null;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryScan, async () => this.requireLibrary().scanLibrary());
|
||||
ipcMain.handle(IPC_CHANNELS.libraryList, async () => this.requireLibrary().listFiles());
|
||||
ipcMain.handle(IPC_CHANNELS.libraryDuplicates, async () => this.requireLibrary().listDuplicates());
|
||||
ipcMain.handle(IPC_CHANNELS.searchQuery, async (_event: IpcMainInvokeEvent, query: string) =>
|
||||
this.requireSearch().search(query)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryRename, async (_event: IpcMainInvokeEvent, fileId: number, newName: string) =>
|
||||
this.requireLibrary().renameFile(fileId, newName)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryMove,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, targetRelativeDirectory: string) =>
|
||||
this.requireLibrary().moveFile(fileId, targetRelativeDirectory)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryOrganize,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) =>
|
||||
this.requireLibrary().organizeFile(fileId, metadata)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryCustomName,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, customName: string | null) =>
|
||||
this.requireLibrary().updateCustomName(fileId, customName)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryOpenFolder, async (_event: IpcMainInvokeEvent, fileId: number) =>
|
||||
this.requireLibrary().openFileFolder(fileId)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryDelete, async (_event: IpcMainInvokeEvent, fileIds: number[]) =>
|
||||
this.requireLibrary().deleteFiles(fileIds)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryBuffer, async (_event: IpcMainInvokeEvent, fileId: number) =>
|
||||
this.requireLibrary().getAudioBuffer(fileId)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryWaveformPreview,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, pointCount: number | undefined) =>
|
||||
this.requireLibrary().getWaveformPreview(fileId, pointCount)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.tagsUpdate, async (_event: IpcMainInvokeEvent, payload: TagUpdatePayload) =>
|
||||
this.requireLibrary().updateTagging(payload)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.categoriesList, async () => this.requireLibrary().listCategories());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryMetadata, async (_event: IpcMainInvokeEvent, fileId: number) =>
|
||||
this.requireLibrary().readFileMetadata(fileId)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryMetadataSuggestions, async () =>
|
||||
this.requireLibrary().listMetadataSuggestions()
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryUpdateMetadata,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) =>
|
||||
this.requireLibrary().updateFileMetadata(fileId, metadata)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves resources that may live either beside the compiled output or in the project root in development.
|
||||
*/
|
||||
private resolveResourcePath(relativePath: string): string {
|
||||
for (const base of this.buildSearchBases()) {
|
||||
const candidate = path.resolve(base, relativePath);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(`Resource ${relativePath} could not be located.`);
|
||||
}
|
||||
|
||||
private resolvePreloadPath(): string {
|
||||
const relativeCandidates = [
|
||||
'dist/main/preload/index.js',
|
||||
'main/preload/index.js',
|
||||
'preload/index.js',
|
||||
'src/preload/index.js'
|
||||
];
|
||||
for (const base of this.buildSearchBases()) {
|
||||
for (const relative of relativeCandidates) {
|
||||
const candidate = path.resolve(base, relative);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to resolve preload script.');
|
||||
}
|
||||
|
||||
private resolveRendererIndex(): string {
|
||||
const relativeCandidates = ['dist/renderer/index.html', 'renderer/index.html', 'src/renderer/index.html'];
|
||||
for (const base of this.buildSearchBases()) {
|
||||
for (const relative of relativeCandidates) {
|
||||
const candidate = path.resolve(base, relative);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Renderer bundle not found.');
|
||||
}
|
||||
|
||||
private resolveCwdFallback(): string {
|
||||
const processRef = (globalThis as { process?: { cwd?: () => string } }).process;
|
||||
if (processRef?.cwd) {
|
||||
return processRef.cwd();
|
||||
}
|
||||
return app.getAppPath();
|
||||
}
|
||||
|
||||
private buildSearchBases(): string[] {
|
||||
const bases = new Set<string>();
|
||||
const appPath = app.getAppPath();
|
||||
bases.add(appPath);
|
||||
bases.add(path.resolve(appPath, '..'));
|
||||
bases.add(this.resolveCwdFallback());
|
||||
return Array.from(bases);
|
||||
}
|
||||
|
||||
private requireSettings(): SettingsService {
|
||||
if (!this.settingsService) {
|
||||
throw new Error('SettingsService has not been initialised.');
|
||||
}
|
||||
return this.settingsService;
|
||||
}
|
||||
|
||||
private requireLibrary(): LibraryService {
|
||||
if (!this.libraryService) {
|
||||
throw new Error('LibraryService has not been initialised.');
|
||||
}
|
||||
return this.libraryService;
|
||||
}
|
||||
|
||||
private requireSearch(): SearchService {
|
||||
if (!this.searchService) {
|
||||
throw new Error('SearchService has not been initialised.');
|
||||
}
|
||||
return this.searchService;
|
||||
}
|
||||
}
|
||||
27
src/main/index.ts
Normal file
27
src/main/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { app } from 'electron';
|
||||
import { MainApp } from './MainApp';
|
||||
|
||||
const mainApp = new MainApp();
|
||||
|
||||
app.whenReady()
|
||||
.then(() => mainApp.initialize())
|
||||
.catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- Logging critical boot failures is necessary for troubleshooting.
|
||||
console.error('Failed to initialise application', error);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
const processRef = (globalThis as { process?: { platform?: string } }).process;
|
||||
if (processRef?.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
mainApp.ensureWindow();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
mainApp.dispose();
|
||||
});
|
||||
507
src/main/services/DatabaseService.ts
Normal file
507
src/main/services/DatabaseService.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Thin wrapper around better-sqlite3 providing schema setup and helpers used by the main process services.
|
||||
*/
|
||||
import Database, { Database as BetterSqliteDatabase } from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { AppSettings, AudioFileSummary, CategoryRecord } from '../../shared/models';
|
||||
|
||||
export interface FileRecordInput {
|
||||
/** Absolute filesystem path. */
|
||||
absolutePath: string;
|
||||
/** Relative path inside the library root. */
|
||||
relativePath: string;
|
||||
/** Filename including extension. */
|
||||
fileName: string;
|
||||
/** Display name without extension. */
|
||||
displayName: string;
|
||||
/** Unix epoch milliseconds. */
|
||||
modifiedAt: number;
|
||||
/** File creation time if available. */
|
||||
createdAt: number | null;
|
||||
/** File size in bytes. */
|
||||
size: number;
|
||||
/** Duration in milliseconds if known. */
|
||||
durationMs: number | null;
|
||||
/** Sample rate in Hz if known. */
|
||||
sampleRate: number | null;
|
||||
/** Bit depth if known. */
|
||||
bitDepth: number | null;
|
||||
/** MD5 checksum of the file contents. */
|
||||
checksum: string | null;
|
||||
/** Optional tag payload (stored as JSON string). */
|
||||
tags?: string[];
|
||||
/** Optional category payload (stored as JSON string). */
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface FileRecordRow extends AudioFileSummary {}
|
||||
|
||||
type DbRow = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Handles persistence for files, categories, and settings.
|
||||
*/
|
||||
export class DatabaseService {
|
||||
private db: BetterSqliteDatabase | null = null;
|
||||
|
||||
public constructor(private readonly dbFilePath: string) {}
|
||||
|
||||
/**
|
||||
* Opens the database connection (creating the file if necessary) and ensures the schema exists.
|
||||
*/
|
||||
public initialize(): void {
|
||||
const folder = path.dirname(this.dbFilePath);
|
||||
if (!fs.existsSync(folder)) {
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
}
|
||||
this.db = new Database(this.dbFilePath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.applySchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the active database connection.
|
||||
*/
|
||||
public close(): void {
|
||||
this.db?.close();
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists or updates a file row and returns the stored record.
|
||||
* @param record File description to persist.
|
||||
*/
|
||||
public upsertFile(record: FileRecordInput): FileRecordRow {
|
||||
const connection = this.requireDb();
|
||||
const statement = connection.prepare(
|
||||
`INSERT INTO files (
|
||||
absolute_path,
|
||||
library_relative_path,
|
||||
file_name,
|
||||
display_name,
|
||||
modified_at,
|
||||
created_at,
|
||||
size_bytes,
|
||||
duration_ms,
|
||||
sample_rate,
|
||||
bit_depth,
|
||||
checksum,
|
||||
tags_json,
|
||||
categories_json
|
||||
) VALUES (
|
||||
@absolutePath,
|
||||
@relativePath,
|
||||
@fileName,
|
||||
@displayName,
|
||||
@modifiedAt,
|
||||
@createdAt,
|
||||
@size,
|
||||
@durationMs,
|
||||
@sampleRate,
|
||||
@bitDepth,
|
||||
@checksum,
|
||||
@tagsJson,
|
||||
@categoriesJson
|
||||
)
|
||||
ON CONFLICT(absolute_path) DO UPDATE SET
|
||||
library_relative_path = excluded.library_relative_path,
|
||||
file_name = excluded.file_name,
|
||||
display_name = excluded.display_name,
|
||||
modified_at = excluded.modified_at,
|
||||
created_at = COALESCE(files.created_at, excluded.created_at),
|
||||
size_bytes = excluded.size_bytes,
|
||||
duration_ms = excluded.duration_ms,
|
||||
sample_rate = excluded.sample_rate,
|
||||
bit_depth = excluded.bit_depth,
|
||||
checksum = excluded.checksum,
|
||||
tags_json = CASE WHEN files.tags_json = '[]' THEN excluded.tags_json ELSE files.tags_json END,
|
||||
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END
|
||||
RETURNING *`
|
||||
);
|
||||
|
||||
const row = statement.get({
|
||||
absolutePath: record.absolutePath,
|
||||
relativePath: record.relativePath,
|
||||
fileName: record.fileName,
|
||||
displayName: record.displayName,
|
||||
modifiedAt: record.modifiedAt,
|
||||
createdAt: record.createdAt,
|
||||
size: record.size,
|
||||
durationMs: record.durationMs,
|
||||
sampleRate: record.sampleRate,
|
||||
bitDepth: record.bitDepth,
|
||||
checksum: record.checksum,
|
||||
tagsJson: JSON.stringify(record.tags ?? []),
|
||||
categoriesJson: JSON.stringify(record.categories ?? [])
|
||||
}) as DbRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
throw new Error('Failed to persist file record.');
|
||||
}
|
||||
|
||||
return this.mapFileRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored tags and categories for a file.
|
||||
*/
|
||||
public updateTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
|
||||
const connection = this.requireDb();
|
||||
const statement = connection.prepare(
|
||||
`UPDATE files
|
||||
SET tags_json = @tags,
|
||||
categories_json = @categories
|
||||
WHERE id = @id
|
||||
RETURNING *`
|
||||
);
|
||||
const row = statement.get({
|
||||
id: fileId,
|
||||
tags: JSON.stringify(tags),
|
||||
categories: JSON.stringify(categories)
|
||||
}) as DbRow | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`File with id ${fileId} not found`);
|
||||
}
|
||||
return this.mapFileRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the custom name for a file.
|
||||
*/
|
||||
public updateCustomName(fileId: number, customName: string | null): AudioFileSummary {
|
||||
const connection = this.requireDb();
|
||||
const statement = connection.prepare(
|
||||
`UPDATE files
|
||||
SET custom_name = @customName
|
||||
WHERE id = @id
|
||||
RETURNING *`
|
||||
);
|
||||
const row = statement.get({
|
||||
id: fileId,
|
||||
customName
|
||||
}) as DbRow | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`File with id ${fileId} not found`);
|
||||
}
|
||||
return this.mapFileRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single file row by id.
|
||||
*/
|
||||
public getFileById(fileId: number): AudioFileSummary {
|
||||
const row = this.requireDb()
|
||||
.prepare('SELECT * FROM files WHERE id = ?')
|
||||
.get(fileId) as DbRow | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`File with id ${fileId} not found`);
|
||||
}
|
||||
return this.mapFileRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all audio files currently known to the database.
|
||||
*/
|
||||
public listFiles(): AudioFileSummary[] {
|
||||
const rows = this.requireDb()
|
||||
.prepare('SELECT * FROM files ORDER BY display_name COLLATE NOCASE ASC')
|
||||
.all() as DbRow[];
|
||||
return rows.map((row) => this.mapFileRow(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file record permanently.
|
||||
*/
|
||||
public deleteFile(fileId: number): void {
|
||||
const statement = this.requireDb().prepare('DELETE FROM files WHERE id = ?');
|
||||
statement.run(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns groups of files that share the same checksum.
|
||||
*/
|
||||
public listDuplicateGroups(): { checksum: string; files: AudioFileSummary[] }[] {
|
||||
const connection = this.requireDb();
|
||||
const checksumRows = connection
|
||||
.prepare(`
|
||||
SELECT checksum
|
||||
FROM files
|
||||
WHERE checksum IS NOT NULL AND checksum <> ''
|
||||
GROUP BY checksum
|
||||
HAVING COUNT(*) > 1
|
||||
`)
|
||||
.all() as Array<{ checksum: string }>;
|
||||
|
||||
const fileQuery = connection.prepare('SELECT * FROM files WHERE checksum = ? ORDER BY created_at ASC, modified_at ASC');
|
||||
return checksumRows.map((row) => ({
|
||||
checksum: row.checksum,
|
||||
files: (fileQuery.all(row.checksum) as DbRow[]).map((fileRow) => this.mapFileRow(fileRow))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes file records whose absolute path is not part of the provided set.
|
||||
* @param validPathsSet Set of absolute paths that should remain in the database.
|
||||
* @returns Number of removed rows.
|
||||
*/
|
||||
public removeFilesOutside(validPathsSet: Set<string>): number {
|
||||
const connection = this.requireDb();
|
||||
const rows = connection.prepare('SELECT id, absolute_path FROM files').all() as DbRow[];
|
||||
let removed = 0;
|
||||
const deleteStatement = connection.prepare('DELETE FROM files WHERE id = ?');
|
||||
for (const row of rows) {
|
||||
const absolutePath = row.absolute_path as string | undefined;
|
||||
const id = row.id as number | undefined;
|
||||
if (!absolutePath || typeof id !== 'number') {
|
||||
continue;
|
||||
}
|
||||
if (!validPathsSet.has(absolutePath)) {
|
||||
deleteStatement.run(id);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates file path and naming metadata after a rename or move operation.
|
||||
*/
|
||||
public updateFileLocation(
|
||||
fileId: number,
|
||||
absolutePath: string,
|
||||
relativePath: string,
|
||||
fileName: string,
|
||||
displayName: string
|
||||
): AudioFileSummary {
|
||||
const row = this.requireDb()
|
||||
.prepare(
|
||||
`UPDATE files
|
||||
SET absolute_path = @absolutePath,
|
||||
library_relative_path = @relativePath,
|
||||
file_name = @fileName,
|
||||
display_name = @displayName
|
||||
WHERE id = @id
|
||||
RETURNING *`
|
||||
)
|
||||
.get({ id: fileId, absolutePath, relativePath, fileName, displayName }) as DbRow | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`File with id ${fileId} not found`);
|
||||
}
|
||||
return this.mapFileRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves application settings as a typed object.
|
||||
*/
|
||||
public getSettings(): AppSettings {
|
||||
const connection = this.requireDb();
|
||||
const rows = connection.prepare('SELECT key, value FROM settings').all() as DbRow[];
|
||||
const map = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const key = row.key as string | undefined;
|
||||
const value = row.value as string | undefined;
|
||||
if (key && typeof value === 'string') {
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
libraryPath: map.has('libraryPath') ? (JSON.parse(map.get('libraryPath') as string) as string) : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a single setting key.
|
||||
*/
|
||||
public setSetting(key: string, value: unknown): void {
|
||||
this.requireDb()
|
||||
.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES (@key, @value)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
)
|
||||
.run({ key, value: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or updates a UCS category record.
|
||||
*/
|
||||
public upsertCategory(category: CategoryRecord): void {
|
||||
this.requireDb()
|
||||
.prepare(
|
||||
`INSERT INTO categories (
|
||||
id,
|
||||
category,
|
||||
sub_category,
|
||||
short_code,
|
||||
explanation,
|
||||
synonyms_json
|
||||
) VALUES (@id, @category, @subCategory, @shortCode, @explanation, @synonyms)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
category = excluded.category,
|
||||
sub_category = excluded.sub_category,
|
||||
short_code = excluded.short_code,
|
||||
explanation = excluded.explanation,
|
||||
synonyms_json = excluded.synonyms_json`
|
||||
)
|
||||
.run({
|
||||
id: category.id,
|
||||
category: category.category,
|
||||
subCategory: category.subCategory,
|
||||
shortCode: category.shortCode,
|
||||
explanation: category.explanation,
|
||||
synonyms: JSON.stringify(category.synonyms)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full catalog of UCS categories.
|
||||
*/
|
||||
public listCategories(): CategoryRecord[] {
|
||||
const rows = this.requireDb()
|
||||
.prepare('SELECT * FROM categories ORDER BY category, sub_category')
|
||||
.all() as DbRow[];
|
||||
return rows.map((row) => ({
|
||||
id: row.id as string,
|
||||
category: row.category as string,
|
||||
subCategory: row.sub_category as string,
|
||||
shortCode: row.short_code as string,
|
||||
explanation: row.explanation as string,
|
||||
synonyms: this.parseJsonArray(row.synonyms_json)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a category by its CatID.
|
||||
*/
|
||||
public getCategoryById(catId: string): CategoryRecord | null {
|
||||
const row = this.requireDb()
|
||||
.prepare('SELECT * FROM categories WHERE id = ?')
|
||||
.get(catId) as DbRow | undefined;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id as string,
|
||||
category: row.category as string,
|
||||
subCategory: row.sub_category as string,
|
||||
shortCode: row.short_code as string,
|
||||
explanation: row.explanation as string,
|
||||
synonyms: this.parseJsonArray(row.synonyms_json)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw database row to the strongly typed summary shape.
|
||||
*/
|
||||
private mapFileRow(row: DbRow): AudioFileSummary {
|
||||
return {
|
||||
id: row.id as number,
|
||||
absolutePath: row.absolute_path as string,
|
||||
relativePath: row.library_relative_path as string,
|
||||
fileName: row.file_name as string,
|
||||
displayName: row.display_name as string,
|
||||
modifiedAt: row.modified_at as number,
|
||||
createdAt: row.created_at === null || row.created_at === undefined ? null : (row.created_at as number),
|
||||
size: row.size_bytes as number,
|
||||
durationMs: row.duration_ms === null ? null : (row.duration_ms as number),
|
||||
sampleRate: row.sample_rate === null ? null : (row.sample_rate as number),
|
||||
bitDepth: row.bit_depth === null ? null : (row.bit_depth as number),
|
||||
checksum: typeof row.checksum === 'string' ? (row.checksum as string) : null,
|
||||
tags: this.parseJsonArray(row.tags_json),
|
||||
categories: this.parseJsonArray(row.categories_json),
|
||||
customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy accessor ensuring the database has been initialised.
|
||||
*/
|
||||
private requireDb(): BetterSqliteDatabase {
|
||||
if (!this.db) {
|
||||
throw new Error('Database connection has not been initialised.');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the initial schema for the application.
|
||||
*/
|
||||
private applySchema(): void {
|
||||
const connection = this.requireDb();
|
||||
connection.exec(`
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
absolute_path TEXT NOT NULL UNIQUE,
|
||||
library_relative_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
duration_ms INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bit_depth INTEGER,
|
||||
checksum TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
categories_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_display_name ON files(display_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
sub_category TEXT NOT NULL,
|
||||
short_code TEXT NOT NULL,
|
||||
explanation TEXT NOT NULL,
|
||||
synonyms_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL,
|
||||
previous_path TEXT NOT NULL,
|
||||
new_path TEXT NOT NULL,
|
||||
operation_type TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Add newly introduced columns if the table already existed.
|
||||
this.addColumnIfMissing(connection, 'files', 'created_at', 'INTEGER');
|
||||
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
|
||||
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
|
||||
|
||||
// Create checksum index after ensuring column exists
|
||||
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');
|
||||
}
|
||||
|
||||
private addColumnIfMissing(connection: BetterSqliteDatabase, table: string, column: string, definition: string): void {
|
||||
const info = connection.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (!info.some((row) => row.name === column)) {
|
||||
connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses stored JSON arrays, handling legacy nulls or malformed values.
|
||||
*/
|
||||
private parseJsonArray(value: unknown): string[] {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
1237
src/main/services/LibraryService.ts
Normal file
1237
src/main/services/LibraryService.ts
Normal file
File diff suppressed because it is too large
Load Diff
127
src/main/services/OrganizationService.ts
Normal file
127
src/main/services/OrganizationService.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import path from 'node:path';
|
||||
import { AudioFileSummary, CategoryRecord } from '../../shared/models';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
/**
|
||||
* Handles automatic file organization based on UCS categories.
|
||||
* Provides helpers for folder resolution, base name creation, and duplicate handling.
|
||||
*/
|
||||
export class OrganizationService {
|
||||
public constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Resolves the primary category record for a file, or null if unavailable.
|
||||
*/
|
||||
public getPrimaryCategory(file: AudioFileSummary): CategoryRecord | null {
|
||||
if (file.categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const categoryId = file.categories[0];
|
||||
return this.database.getCategoryById(categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the folder path for the provided category (CATEGORY/SUBCATEGORY).
|
||||
*/
|
||||
public buildFolderPath(category: CategoryRecord): string {
|
||||
const categoryFolder = category.category.toUpperCase().replace(/\s+/g, '_');
|
||||
const subCategoryFolder = category.subCategory.toUpperCase().replace(/\s+/g, '_');
|
||||
return path.join(categoryFolder, subCategoryFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the base name (without extension) used for organizing files.
|
||||
* Combines the short code with the suffix from CatID, plus custom name if provided.
|
||||
* Example: CatID "VEHUtil" + CatShort "VEH" → "VEH_Util"
|
||||
*/
|
||||
public buildBaseName(category: CategoryRecord, customName?: string): string {
|
||||
const shortCode = category.shortCode.toUpperCase();
|
||||
const suffix = this.extractCatIdSuffix(category.id, category.shortCode);
|
||||
|
||||
let baseName = shortCode;
|
||||
if (suffix) {
|
||||
baseName = `${shortCode}_${suffix}`;
|
||||
}
|
||||
|
||||
if (customName) {
|
||||
const clean = this.sanitizeCustomName(customName);
|
||||
if (clean) {
|
||||
baseName = `${baseName}_${clean}`;
|
||||
}
|
||||
}
|
||||
|
||||
return baseName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the suffix from CatID by removing the CatShort prefix.
|
||||
* Example: extractCatIdSuffix("VEHUtil", "VEH") → "Util"
|
||||
*/
|
||||
private extractCatIdSuffix(catId: string, catShort: string): string {
|
||||
if (!catId.toLowerCase().startsWith(catShort.toLowerCase())) {
|
||||
return '';
|
||||
}
|
||||
const suffix = catId.substring(catShort.length);
|
||||
return suffix.charAt(0).toUpperCase() + suffix.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists existing files inside the target folder that share the provided base name.
|
||||
*/
|
||||
public listConflictingFiles(folderPath: string, baseName: string): AudioFileSummary[] {
|
||||
const normalizedFolder = this.normalizePath(folderPath);
|
||||
const pattern = new RegExp(`^${baseName}(?:_(\\d+))?\\.wav$`, 'i');
|
||||
return this.database
|
||||
.listFiles()
|
||||
.filter((file) => this.normalizePath(path.dirname(file.relativePath)) === normalizedFolder)
|
||||
.filter((file) => pattern.test(file.fileName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the next available sequence number for the given base name within a folder.
|
||||
*/
|
||||
public findNextAvailableNumber(folderPath: string, baseName: string): number {
|
||||
const conflicts = this.listConflictingFiles(folderPath, baseName);
|
||||
if (conflicts.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
const pattern = new RegExp(`^${baseName}_(\\d+)\\.wav$`, 'i');
|
||||
const numbers = conflicts
|
||||
.map((file) => {
|
||||
const match = file.fileName.match(pattern);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter((value) => value > 0);
|
||||
if (numbers.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(...numbers) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a sequence number with leading zeros.
|
||||
*/
|
||||
public formatSequenceNumber(index: number): string {
|
||||
return index.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the sanitiser so the caller can reuse consistent logic.
|
||||
* Capitalizes the first letter of each word and replaces spaces with underscores.
|
||||
*/
|
||||
public sanitizeCustomName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join('_');
|
||||
}
|
||||
|
||||
private normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
}
|
||||
189
src/main/services/SearchService.ts
Normal file
189
src/main/services/SearchService.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import { AudioFileSummary } from '../../shared/models';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { TagService } from './TagService';
|
||||
|
||||
/**
|
||||
* Lightweight snapshot of metadata required for advanced search filters.
|
||||
*/
|
||||
interface CachedMetadata {
|
||||
author?: string;
|
||||
copyright?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a structured filter derived from the freeform query.
|
||||
*/
|
||||
interface AdvancedFilter {
|
||||
field: 'author' | 'copyright';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides fuzzy search capabilities backed by Fuse.js and the database content.
|
||||
*/
|
||||
export class SearchService {
|
||||
private fuse: Fuse<AudioFileSummary> | null = null;
|
||||
private cache: AudioFileSummary[] = [];
|
||||
private readonly metadataCache = new Map<number, CachedMetadata>();
|
||||
|
||||
public constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly tagService: TagService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rebuilds the in-memory index from the current database contents.
|
||||
*/
|
||||
public rebuildIndex(): void {
|
||||
this.cache = this.database.listFiles();
|
||||
this.metadataCache.clear();
|
||||
this.fuse = new Fuse(this.cache, {
|
||||
includeScore: true,
|
||||
threshold: 0.35,
|
||||
keys: [
|
||||
{ name: 'displayName', weight: 0.3 },
|
||||
{ name: 'fileName', weight: 0.2 },
|
||||
{ name: 'relativePath', weight: 0.2 },
|
||||
{ name: 'tags', weight: 0.2 },
|
||||
{ name: 'categories', weight: 0.1 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a fuzzy search query, falling back to the full collection when the input is empty.
|
||||
*/
|
||||
public search(query: string): AudioFileSummary[] {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
const { filters, remainingQuery } = this.extractAdvancedFilters(trimmed);
|
||||
let candidates = this.getAll();
|
||||
|
||||
if (filters.length > 0) {
|
||||
candidates = candidates.filter((file) => this.matchesAdvancedFilters(file, filters));
|
||||
}
|
||||
|
||||
if (remainingQuery.length === 0) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const fuseInstance = filters.length > 0
|
||||
? new Fuse(candidates, this.createFuseOptions())
|
||||
: this.ensureFuse();
|
||||
|
||||
return fuseInstance
|
||||
.search(remainingQuery)
|
||||
.map((result: Fuse.FuseResult<AudioFileSummary>) => result.item)
|
||||
.filter((file) => (filters.length === 0 ? true : this.matchesAdvancedFilters(file, filters)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the cached collection, lazily rebuilding the index on first access.
|
||||
*/
|
||||
public getAll(): AudioFileSummary[] {
|
||||
if (this.cache.length === 0) {
|
||||
this.rebuildIndex();
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a Fuse instance is available, recreating it when the cache is stale.
|
||||
*/
|
||||
private ensureFuse(): Fuse<AudioFileSummary> {
|
||||
if (!this.fuse) {
|
||||
this.rebuildIndex();
|
||||
}
|
||||
return this.fuse ?? new Fuse(this.getAll(), this.createFuseOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the standard Fuse configuration used by the service.
|
||||
*/
|
||||
private createFuseOptions(): Fuse.IFuseOptions<AudioFileSummary> {
|
||||
return {
|
||||
includeScore: true,
|
||||
threshold: 0.35,
|
||||
keys: [
|
||||
{ name: 'displayName', weight: 0.3 },
|
||||
{ name: 'fileName', weight: 0.2 },
|
||||
{ name: 'relativePath', weight: 0.2 },
|
||||
{ name: 'tags', weight: 0.2 },
|
||||
{ name: 'categories', weight: 0.1 }
|
||||
]
|
||||
} satisfies Fuse.IFuseOptions<AudioFileSummary>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls out author/copyright filters and returns the remaining free text query.
|
||||
*/
|
||||
private extractAdvancedFilters(query: string): { filters: AdvancedFilter[]; remainingQuery: string } {
|
||||
const tokens = query.split(/\s+/);
|
||||
const filters: AdvancedFilter[] = [];
|
||||
const remainingTokens: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
const lower = token.toLowerCase();
|
||||
if (lower.startsWith('author:')) {
|
||||
const value = token.slice('author:'.length).trim();
|
||||
if (value.length > 0) {
|
||||
filters.push({ field: 'author', value: value.toLowerCase() });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith('copyright:')) {
|
||||
const value = token.slice('copyright:'.length).trim();
|
||||
if (value.length > 0) {
|
||||
filters.push({ field: 'copyright', value: value.toLowerCase() });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
remainingTokens.push(token);
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
remainingQuery: remainingTokens.join(' ').trim()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether a file satisfies all extracted advanced filters.
|
||||
*/
|
||||
private matchesAdvancedFilters(file: AudioFileSummary, filters: AdvancedFilter[]): boolean {
|
||||
if (filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metadata = this.getCachedMetadata(file);
|
||||
return filters.every((filter) => {
|
||||
const source = filter.field === 'author' ? metadata.author : metadata.copyright;
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
return source.toLowerCase().includes(filter.value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads metadata once per file and caches it for reuse across searches in the same session.
|
||||
*/
|
||||
private getCachedMetadata(file: AudioFileSummary): CachedMetadata {
|
||||
const cached = this.metadataCache.get(file.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const metadata = this.tagService.readMetadata(file.absolutePath);
|
||||
const normalized: CachedMetadata = {
|
||||
author: metadata.author?.trim() || undefined,
|
||||
copyright: metadata.copyright?.trim() || undefined
|
||||
};
|
||||
this.metadataCache.set(file.id, normalized);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
60
src/main/services/SettingsService.ts
Normal file
60
src/main/services/SettingsService.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import { AppSettings } from '../../shared/models';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
/**
|
||||
* Manages persistent application settings backed by the SQLite database.
|
||||
*/
|
||||
export class SettingsService {
|
||||
public constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Reads the current settings snapshot.
|
||||
*/
|
||||
public getSettings(): AppSettings {
|
||||
return this.database.getSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured library path or null when not set.
|
||||
*/
|
||||
public getLibraryPath(): string | null {
|
||||
return this.database.getSettings().libraryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures there is a library path stored, defaulting to the user's Music directory.
|
||||
*/
|
||||
public ensureLibraryPath(): string {
|
||||
const settings = this.database.getSettings();
|
||||
if (settings.libraryPath) {
|
||||
this.ensureDirectory(settings.libraryPath);
|
||||
return settings.libraryPath;
|
||||
}
|
||||
const defaultPath = path.join(app.getPath('music'), 'AudioSortLibrary');
|
||||
this.ensureDirectory(defaultPath);
|
||||
this.database.setSetting('libraryPath', defaultPath);
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored library path after validating and normalising it.
|
||||
*/
|
||||
public updateLibraryPath(targetPath: string): AppSettings {
|
||||
const normalised = path.resolve(targetPath);
|
||||
this.ensureDirectory(normalised);
|
||||
this.database.setSetting('libraryPath', normalised);
|
||||
return this.database.getSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantees that the desired directory exists.
|
||||
*/
|
||||
private ensureDirectory(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
217
src/main/services/TagService.ts
Normal file
217
src/main/services/TagService.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import fs from 'node:fs';
|
||||
import { WaveFile } from 'wavefile';
|
||||
import { AudioFileSummary } from '../../shared/models';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
/**
|
||||
* Coordinates tag persistence between the database and embedded WAV metadata.
|
||||
*/
|
||||
export class TagService {
|
||||
public constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Applies tags and categories to a file record and embeds the metadata into the WAV container.
|
||||
*/
|
||||
public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
|
||||
const normalisedTags = this.normaliseValues(tags);
|
||||
const normalisedCategories = this.normaliseValues(categories);
|
||||
const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories);
|
||||
this.writeWaveMetadata(updated.absolutePath, {
|
||||
tags: normalisedTags,
|
||||
categories: normalisedCategories
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises incoming arrays by trimming whitespace, removing empties, and deduplicating.
|
||||
*/
|
||||
private normaliseValues(values: string[]): string[] {
|
||||
const result = new Set<string>();
|
||||
for (const value of values) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads embedded metadata from a WAV file.
|
||||
*/
|
||||
public readMetadata(filePath: string): {
|
||||
author?: string;
|
||||
copyright?: string;
|
||||
title?: string;
|
||||
rating?: number;
|
||||
} {
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const wave = new WaveFile(buffer);
|
||||
const tags = this.extractInfoTagMap(wave);
|
||||
const ratingValue = tags.IRTD ? Number.parseInt(tags.IRTD, 10) : undefined;
|
||||
let rating: number | undefined;
|
||||
if (typeof ratingValue === 'number' && Number.isFinite(ratingValue)) {
|
||||
rating = Math.floor(ratingValue / 2);
|
||||
}
|
||||
|
||||
return {
|
||||
author: tags.IART?.trim() || undefined,
|
||||
copyright: tags.ICOP?.trim() || undefined,
|
||||
title: tags.INAM?.trim() || undefined,
|
||||
rating
|
||||
};
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
||||
console.warn('Failed to read WAV metadata', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes tags and categories to an organized file as embedded metadata.
|
||||
*/
|
||||
public writeMetadataOnly(
|
||||
filePath: string,
|
||||
metadata: {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
copyright?: string | null;
|
||||
rating?: number;
|
||||
}
|
||||
): void {
|
||||
this.writeWaveMetadata(filePath, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a simple INFO chunk with the provided metadata. Failures are swallowed so DB state remains authoritative.
|
||||
*/
|
||||
private writeWaveMetadata(
|
||||
filePath: string,
|
||||
metadata: {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
copyright?: string | null;
|
||||
rating?: number;
|
||||
}
|
||||
): void {
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const wave = new WaveFile(buffer);
|
||||
const waveWithInfo = wave as WaveFile & {
|
||||
setTag?: (tag: string, value: string) => void;
|
||||
listInfoTags?: Record<string, string>;
|
||||
};
|
||||
|
||||
const tagValuesList = metadata.tags.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
const categoryValuesList = metadata.categories.map((entry) => entry.trim()).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 tagValues: Record<string, string | null> = {
|
||||
IKEY: tagText,
|
||||
ICMT: tagText,
|
||||
ISBJ: categoryText,
|
||||
ISUB: primaryCategory,
|
||||
IGNR: null,
|
||||
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
|
||||
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
|
||||
ICOP: metadata.copyright?.trim()?.length ? metadata.copyright.trim() : null,
|
||||
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
|
||||
ISFT: 'AudioSort'
|
||||
};
|
||||
|
||||
this.applyInfoTags(waveWithInfo, tagValues);
|
||||
|
||||
const updatedBuffer = wave.toBuffer();
|
||||
fs.writeFileSync(filePath, updatedBuffer);
|
||||
|
||||
console.log(`Wrote metadata to ${filePath}:`, {
|
||||
tags: metadata.tags.join(', '),
|
||||
categories: metadata.categories.join(', '),
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
copyright: metadata.copyright,
|
||||
rating: metadata.rating
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
||||
console.warn('Failed to write WAV metadata', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the INFO chunk tag map for low-level metadata consumers.
|
||||
*/
|
||||
public readInfoTags(filePath: string): Partial<Record<string, string>> {
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const wave = new WaveFile(buffer);
|
||||
return this.extractInfoTagMap(wave);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console -- Metadata parsing failures are non-fatal.
|
||||
console.warn('Failed to read WAV info tags', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies INFO chunk updates, clearing values when the metadata source is empty.
|
||||
*/
|
||||
private applyInfoTags(
|
||||
wave: WaveFile & { setTag?: (tag: string, value: string) => void; listInfoTags?: Record<string, string> },
|
||||
entries: Record<string, string | null>
|
||||
): void {
|
||||
const listInfo = wave.listInfoTags ?? null;
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
const normalised = value ?? '';
|
||||
if (typeof wave.setTag === 'function') {
|
||||
wave.setTag(key, normalised);
|
||||
}
|
||||
if (listInfo) {
|
||||
if (normalised.length === 0) {
|
||||
delete listInfo[key];
|
||||
} else {
|
||||
listInfo[key] = normalised;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts INFO chunk entries from a WaveFile instance.
|
||||
*/
|
||||
private extractInfoTagMap(wave: WaveFile): Partial<Record<string, string>> {
|
||||
const waveWithTags = wave as WaveFile & {
|
||||
listTags?: () => Record<string, string>;
|
||||
LIST?: Array<{ format: string; subChunks: Array<{ chunkId: string; value: string }> }>;
|
||||
};
|
||||
|
||||
if (typeof waveWithTags.listTags === 'function') {
|
||||
return waveWithTags.listTags() as Record<string, string>;
|
||||
}
|
||||
|
||||
if (Array.isArray(waveWithTags.LIST)) {
|
||||
const listEntries = waveWithTags.LIST as Array<{
|
||||
format: string;
|
||||
subChunks: Array<{ chunkId: string; value: string }>;
|
||||
}>;
|
||||
const infoChunk = listEntries.find((entry) => entry.format === 'INFO');
|
||||
if (infoChunk) {
|
||||
return infoChunk.subChunks.reduce<Record<string, string>>((acc, chunk) => {
|
||||
acc[chunk.chunkId] = chunk.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user