This commit is contained in:
2025-11-06 02:27:46 +11:00
commit e5d558fd98
51 changed files with 11385 additions and 0 deletions

374
src/main/MainApp.ts Normal file
View 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
View 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();
});

View 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 [];
}
}
}

File diff suppressed because it is too large Load Diff

View 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, '/');
}
}

View 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;
}
}

View 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 });
}
}
}

View 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 {};
}
}