mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 02:36: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 {};
|
||||
}
|
||||
}
|
||||
83
src/preload/index.ts
Normal file
83
src/preload/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS, type RendererApi } from '../shared/ipc';
|
||||
import type {
|
||||
AppSettings,
|
||||
AudioBufferPayload,
|
||||
AudioFileSummary,
|
||||
CategoryRecord,
|
||||
LibraryScanSummary,
|
||||
TagUpdatePayload
|
||||
} from '../shared/models';
|
||||
|
||||
/**
|
||||
* Preload bridge exposing a curated API surface to the renderer process.
|
||||
*/
|
||||
const api: RendererApi = {
|
||||
async getSettings(): Promise<AppSettings> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.settingsGet);
|
||||
},
|
||||
async selectLibraryDirectory(): Promise<string | null> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.dialogSelectLibrary);
|
||||
},
|
||||
async setLibraryPath(path: string): Promise<AppSettings> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.settingsSetLibrary, path);
|
||||
},
|
||||
async rescanLibrary(): Promise<LibraryScanSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryScan);
|
||||
},
|
||||
async listAudioFiles(): Promise<AudioFileSummary[]> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryList);
|
||||
},
|
||||
async listDuplicates(): Promise<{ checksum: string; files: AudioFileSummary[] }[]> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryDuplicates);
|
||||
},
|
||||
async renameFile(fileId: number, newFileName: string): Promise<AudioFileSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryRename, fileId, newFileName);
|
||||
},
|
||||
async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryMove, fileId, targetRelativeDirectory);
|
||||
},
|
||||
async organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<AudioFileSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryOrganize, fileId, metadata);
|
||||
},
|
||||
async updateCustomName(fileId: number, customName: string | null): Promise<AudioFileSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryCustomName, fileId, customName);
|
||||
},
|
||||
async openFileFolder(fileId: number): Promise<void> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryOpenFolder, fileId);
|
||||
},
|
||||
async deleteFiles(fileIds: number[]): Promise<void> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryDelete, fileIds);
|
||||
},
|
||||
async getAudioBuffer(fileId: number): Promise<AudioBufferPayload> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryBuffer, fileId);
|
||||
},
|
||||
async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryWaveformPreview, fileId, pointCount);
|
||||
},
|
||||
async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.tagsUpdate, payload);
|
||||
},
|
||||
async listCategories(): Promise<CategoryRecord[]> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.categoriesList);
|
||||
},
|
||||
async search(query: string): Promise<AudioFileSummary[]> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.searchQuery, query);
|
||||
},
|
||||
async readFileMetadata(fileId: number): Promise<{ author?: string; copyright?: string; title?: string; rating?: number }> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryMetadata, fileId);
|
||||
},
|
||||
async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryMetadataSuggestions);
|
||||
},
|
||||
async updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void> {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata);
|
||||
},
|
||||
onMenuAction(channel: string, callback: () => void): () => void {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AudioSort</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
293
src/renderer/src/App.tsx
Normal file
293
src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import FileList from './components/FileList';
|
||||
import FileDetailPanel from './components/FileDetailPanel';
|
||||
import MultiFileEditor from './components/MultiFileEditor';
|
||||
import CategorySidebar from './components/CategorySidebar';
|
||||
import AudioPlayer from './components/AudioPlayer';
|
||||
import SettingsDialog from './components/SettingsDialog';
|
||||
import DuplicateComparisonDialog from './components/DuplicateComparisonDialog';
|
||||
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
|
||||
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
|
||||
import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore';
|
||||
import type { AudioFileSummary } from '../../shared/models';
|
||||
|
||||
/**
|
||||
* Root renderer component orchestrating layout and interactions.
|
||||
*/
|
||||
function App(): JSX.Element {
|
||||
const library = useLibrarySnapshot();
|
||||
const player = usePlayerSnapshot();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [duplicateGroups, setDuplicateGroups] = useState<{ checksum: string; files: AudioFileSummary[] }[] | null>(null);
|
||||
const [showStatusMessage, setShowStatusMessage] = useState(false);
|
||||
const [statusFadingOut, setStatusFadingOut] = useState(false);
|
||||
|
||||
const statusMessage = useMemo(() => {
|
||||
if (!library.lastScan) {
|
||||
return null;
|
||||
}
|
||||
const { added, updated, removed } = library.lastScan;
|
||||
return `Scan complete: +${added} updated ${updated} removed ${removed}`;
|
||||
}, [library.lastScan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusMessage) {
|
||||
setShowStatusMessage(true);
|
||||
setStatusFadingOut(false);
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setStatusFadingOut(true);
|
||||
}, 4500);
|
||||
const hideTimer = setTimeout(() => {
|
||||
setShowStatusMessage(false);
|
||||
setStatusFadingOut(false);
|
||||
}, 5000);
|
||||
return () => {
|
||||
clearTimeout(fadeTimer);
|
||||
clearTimeout(hideTimer);
|
||||
};
|
||||
}
|
||||
}, [statusMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true));
|
||||
const cleanup2 = window.api.onMenuAction('rescan-library', handleRescan);
|
||||
const cleanup3 = window.api.onMenuAction('find-duplicates', handleFindDuplicates);
|
||||
|
||||
return () => {
|
||||
cleanup1();
|
||||
cleanup2();
|
||||
cleanup3();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedFile = useMemo(
|
||||
() => library.files.find((file) => file.id === library.selectedFileId) ?? null,
|
||||
[library.files, library.selectedFileId]
|
||||
);
|
||||
|
||||
const selectedFiles = useMemo(
|
||||
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
|
||||
[library.files, library.selectedFileIds]
|
||||
);
|
||||
|
||||
const isMultiSelect = selectedFiles.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMultiSelect) {
|
||||
loadPlayerFile(selectedFile, false);
|
||||
}
|
||||
}, [selectedFile?.id, isMultiSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleArrowNavigation = (event: KeyboardEvent) => {
|
||||
if (event.code !== 'ArrowUp' && event.code !== 'ArrowDown') {
|
||||
return;
|
||||
}
|
||||
if (event.altKey || event.metaKey || event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const tagName = target.tagName;
|
||||
const isEditable = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || target.isContentEditable;
|
||||
if (isEditable) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
libraryStore.selectAdjacent(event.code === 'ArrowUp' ? -1 : 1);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleArrowNavigation);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleArrowNavigation);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
void libraryStore.search(value);
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
await libraryStore.rescan();
|
||||
};
|
||||
|
||||
const handleFindDuplicates = async () => {
|
||||
const duplicates = await window.api.listDuplicates();
|
||||
setDuplicateGroups(duplicates);
|
||||
};
|
||||
|
||||
const handleKeepDuplicate = async (fileIdToKeep: number, fileIdsToDelete: number[]) => {
|
||||
await window.api.deleteFiles(fileIdsToDelete);
|
||||
await libraryStore.bootstrap();
|
||||
};
|
||||
|
||||
const handleCloseDuplicates = () => {
|
||||
setDuplicateGroups(null);
|
||||
};
|
||||
|
||||
const handleSelectFile = (fileId: number, options?: { multi?: boolean; range?: boolean }) => {
|
||||
libraryStore.selectFile(fileId, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects every visible file so keyboard shortcuts mirror multi-select gestures.
|
||||
*/
|
||||
const handleSelectAllFiles = () => {
|
||||
libraryStore.selectAllVisibleFiles();
|
||||
};
|
||||
|
||||
const handlePlayFile = (file: AudioFileSummary) => {
|
||||
loadPlayerFile(file, true);
|
||||
};
|
||||
|
||||
const handleCategorySelect = (filter: CategoryFilterValue) => {
|
||||
libraryStore.setCategoryFilter(filter);
|
||||
};
|
||||
|
||||
const handleRename = async (newName: string) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
await libraryStore.renameFile(selectedFile.id, newName);
|
||||
};
|
||||
|
||||
const handleMove = async (targetDirectory: string) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
await libraryStore.moveFile(selectedFile.id, targetDirectory);
|
||||
};
|
||||
|
||||
const handleOrganize = async (metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
await libraryStore.organizeFile(selectedFile.id, metadata);
|
||||
};
|
||||
|
||||
const handleUpdateCustomName = async (customName: string | null) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
await libraryStore.updateCustomName(selectedFile.id, customName);
|
||||
};
|
||||
|
||||
const handleTagUpdate = async (data: { tags: string[]; categories: string[] }) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
await libraryStore.updateTagging({ fileId: selectedFile.id, ...data });
|
||||
};
|
||||
|
||||
const handleMultiFileTagUpdate = async (fileId: number, data: { tags: string[]; categories: string[] }) => {
|
||||
await libraryStore.updateTagging({ fileId, ...data });
|
||||
};
|
||||
|
||||
const handleMultiFileCustomName = async (fileId: number, customName: string | null) => {
|
||||
await libraryStore.updateCustomName(fileId, customName);
|
||||
};
|
||||
|
||||
const handleMultiFileOrganize = async (fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => {
|
||||
await libraryStore.organizeFile(fileId, metadata);
|
||||
};
|
||||
|
||||
const handleMultiFileUpdateMetadata = async (fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) => {
|
||||
await libraryStore.updateFileMetadata(fileId, metadata);
|
||||
};
|
||||
|
||||
const handleDropFilesToCategory = async (fileIds: number[], categoryId: string) => {
|
||||
for (const fileId of fileIds) {
|
||||
const file = library.files.find((f) => f.id === fileId);
|
||||
if (!file) continue;
|
||||
|
||||
const existingCategories = file.categories;
|
||||
if (existingCategories.includes(categoryId)) continue;
|
||||
|
||||
const newCategories = [...existingCategories, categoryId];
|
||||
await libraryStore.updateTagging({
|
||||
fileId,
|
||||
tags: file.tags,
|
||||
categories: newCategories
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibraryPath = async (path: string) => {
|
||||
await libraryStore.setLibraryPath(path);
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
{showStatusMessage && statusMessage && (
|
||||
<div className={`status-banner ${statusFadingOut ? 'fade-out' : ''}`}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
<main className="app-body">
|
||||
<CategorySidebar
|
||||
categories={library.categories}
|
||||
files={library.files}
|
||||
activeFilter={library.categoryFilter}
|
||||
onSelect={handleCategorySelect}
|
||||
onDropFiles={handleDropFilesToCategory}
|
||||
/>
|
||||
<FileList
|
||||
files={library.visibleFiles}
|
||||
selectedId={library.selectedFileId}
|
||||
selectedIds={library.selectedFileIds}
|
||||
onSelect={handleSelectFile}
|
||||
onPlay={handlePlayFile}
|
||||
searchValue={library.searchQuery}
|
||||
onSearchChange={handleSearch}
|
||||
onSelectAll={handleSelectAllFiles}
|
||||
/>
|
||||
<div className="app-details">
|
||||
{isMultiSelect ? (
|
||||
<MultiFileEditor
|
||||
files={selectedFiles}
|
||||
categories={library.categories}
|
||||
onUpdateTags={handleMultiFileTagUpdate}
|
||||
onUpdateCustomName={handleMultiFileCustomName}
|
||||
onOrganize={handleMultiFileOrganize}
|
||||
onUpdateMetadata={handleMultiFileUpdateMetadata}
|
||||
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FileDetailPanel
|
||||
file={selectedFile}
|
||||
categories={library.categories}
|
||||
onRename={handleRename}
|
||||
onMove={handleMove}
|
||||
onOrganize={handleOrganize}
|
||||
onUpdateTags={handleTagUpdate}
|
||||
onUpdateCustomName={handleUpdateCustomName}
|
||||
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
|
||||
/>
|
||||
<AudioPlayer snapshot={player} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
currentPath={library.settings.libraryPath}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSelectDirectory={handleLibraryPath}
|
||||
/>
|
||||
{duplicateGroups && (
|
||||
<DuplicateComparisonDialog
|
||||
duplicateGroups={duplicateGroups}
|
||||
onKeepFile={handleKeepDuplicate}
|
||||
onClose={handleCloseDuplicates}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
361
src/renderer/src/components/AudioPlayer.tsx
Normal file
361
src/renderer/src/components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { PlayerSnapshot } from '../stores/PlayerStore';
|
||||
import { Waveform } from './Waveform';
|
||||
|
||||
const MIN_VOLUME_DB = -48;
|
||||
const DEFAULT_VOLUME_DB = -24;
|
||||
const VOLUME_STORAGE_KEY = 'audio-player.volume-slider';
|
||||
|
||||
const DEFAULT_VOLUME_SLIDER = (() => {
|
||||
const range = 0 - MIN_VOLUME_DB;
|
||||
if (range === 0) {
|
||||
return 1;
|
||||
}
|
||||
const slider = (DEFAULT_VOLUME_DB - MIN_VOLUME_DB) / range;
|
||||
if (!Number.isFinite(slider)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(1, Math.max(0, slider));
|
||||
})();
|
||||
|
||||
/**
|
||||
* Reads the persisted volume slider position from local storage, falling back to the default slider value.
|
||||
*/
|
||||
function loadStoredVolumeSlider(): number {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_VOLUME_SLIDER;
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(VOLUME_STORAGE_KEY);
|
||||
if (raw === null) {
|
||||
return DEFAULT_VOLUME_SLIDER;
|
||||
}
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_VOLUME_SLIDER;
|
||||
}
|
||||
if (parsed < 0 || parsed > 1) {
|
||||
return DEFAULT_VOLUME_SLIDER;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return DEFAULT_VOLUME_SLIDER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the volume slider position to local storage, guarding against storage errors.
|
||||
*/
|
||||
function persistVolumeSlider(value: number): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(VOLUME_STORAGE_KEY, value.toFixed(4));
|
||||
} catch {
|
||||
// Ignored: local storage may be unavailable (private mode, quotas, etc.).
|
||||
}
|
||||
}
|
||||
|
||||
export interface AudioPlayerProps {
|
||||
snapshot: PlayerSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seamless audio player that integrates smoothly with the interface.
|
||||
*/
|
||||
export function AudioPlayer({ snapshot }: AudioPlayerProps): JSX.Element {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volumeSlider, setVolumeSlider] = useState<number>(() => loadStoredVolumeSlider());
|
||||
const volumeSliderRef = useRef(volumeSlider);
|
||||
const isSettingVolumeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
volumeSliderRef.current = volumeSlider;
|
||||
persistVolumeSlider(volumeSlider);
|
||||
}, [volumeSlider]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
const targetVolume = sliderToVolume(volumeSlider);
|
||||
if (Math.abs(audio.volume - targetVolume) < 0.0001) {
|
||||
return;
|
||||
}
|
||||
isSettingVolumeRef.current = true;
|
||||
audio.volume = targetVolume;
|
||||
setTimeout(() => {
|
||||
isSettingVolumeRef.current = false;
|
||||
}, 0);
|
||||
}, [volumeSlider]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || snapshot.status !== 'ready' || !snapshot.audioUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
if (!audio.paused) {
|
||||
audio.pause();
|
||||
}
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
|
||||
|
||||
const updateTime = () => {
|
||||
if (audio && !audio.paused) {
|
||||
setCurrentTime(audio.currentTime);
|
||||
rafId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
setIsPlaying(true);
|
||||
rafId = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false);
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDurationChange = () => setDuration(audio.duration);
|
||||
const handleVolumeChange = () => {
|
||||
if (isSettingVolumeRef.current) {
|
||||
return;
|
||||
}
|
||||
const nextSlider = volumeToSlider(audio.volume);
|
||||
setVolumeSlider(nextSlider);
|
||||
volumeSliderRef.current = nextSlider;
|
||||
};
|
||||
const handleLoadedMetadata = () => {
|
||||
isSettingVolumeRef.current = true;
|
||||
audio.volume = sliderToVolume(volumeSliderRef.current);
|
||||
setTimeout(() => {
|
||||
isSettingVolumeRef.current = false;
|
||||
}, 0);
|
||||
setCurrentTime(0);
|
||||
setDuration(audio.duration);
|
||||
if (snapshot.autoPlay) {
|
||||
void audio.play().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
isSettingVolumeRef.current = true;
|
||||
audio.volume = sliderToVolume(volumeSliderRef.current);
|
||||
setTimeout(() => {
|
||||
isSettingVolumeRef.current = false;
|
||||
}, 0);
|
||||
audio.addEventListener('play', handlePlay);
|
||||
audio.addEventListener('pause', handlePause);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
audio.addEventListener('durationchange', handleDurationChange);
|
||||
audio.addEventListener('volumechange', handleVolumeChange);
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
|
||||
audio.src = snapshot.audioUrl;
|
||||
audio.load();
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
audio.removeEventListener('play', handlePlay);
|
||||
audio.removeEventListener('pause', handlePause);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.removeEventListener('durationchange', handleDurationChange);
|
||||
audio.removeEventListener('volumechange', handleVolumeChange);
|
||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
};
|
||||
}, [snapshot.loadCount, snapshot.autoPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== 'Space') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const tagName = target.tagName;
|
||||
const isEditableElement = tagName === 'INPUT' || tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
if (isEditableElement) {
|
||||
const isRangeInput = target instanceof HTMLInputElement && target.type === 'range';
|
||||
if (!isRangeInput) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
togglePlayPause();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused || audio.ended) {
|
||||
void audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const time = parseFloat(e.target.value);
|
||||
audio.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const sliderPosition = Math.max(0, Math.min(1, parseFloat(e.target.value)));
|
||||
const amplitude = sliderToVolume(sliderPosition);
|
||||
audio.volume = amplitude;
|
||||
setVolumeSlider(sliderPosition);
|
||||
volumeSliderRef.current = sliderPosition;
|
||||
};
|
||||
|
||||
const volumeAmplitude = sliderToVolume(volumeSlider);
|
||||
const volumeIcon = volumeAmplitude === 0 ? '🔇' : volumeAmplitude < 0.4 ? '🔉' : '🔊';
|
||||
const volumeDecibels = sliderToDecibels(volumeSlider);
|
||||
const volumeLabel = formatDecibels(volumeDecibels);
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!isFinite(seconds)) return '00:00.000';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
|
||||
};
|
||||
|
||||
if (snapshot.status === 'idle') {
|
||||
return <div className="player player--empty">Select a file to play</div>;
|
||||
}
|
||||
|
||||
if (snapshot.status === 'loading') {
|
||||
return <div className="player player--loading">Loading audio…</div>;
|
||||
}
|
||||
|
||||
if (snapshot.status === 'error') {
|
||||
return <div className="player player--error">{snapshot.error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="player">
|
||||
<audio ref={audioRef} preload="metadata">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
<div className="player-controls">
|
||||
<button type="button" className="player-button" onClick={togglePlayPause} aria-label={isPlaying ? 'Pause' : 'Play'}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={isPlaying ? 'player-button-icon player-button-icon--pause' : 'player-button-icon player-button-icon--play'}
|
||||
/>
|
||||
</button>
|
||||
<div className="player-progress">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-slider-container">
|
||||
<Waveform
|
||||
audioUrl={snapshot.audioUrl}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
className="player-waveform"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="player-slider"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
step="0.001"
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
<div className="player-volume">
|
||||
<span className="player-volume-icon">{volumeIcon}</span>
|
||||
<span className="player-volume-db">{volumeLabel}</span>
|
||||
<input
|
||||
type="range"
|
||||
className="player-slider player-slider--volume"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volumeSlider}
|
||||
onChange={handleVolumeChange}
|
||||
aria-label="Volume"
|
||||
aria-valuetext={volumeLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sliderToDecibels(value: number): number {
|
||||
if (value <= 0) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
return MIN_VOLUME_DB + (0 - MIN_VOLUME_DB) * clamped;
|
||||
}
|
||||
|
||||
function sliderToVolume(value: number): number {
|
||||
if (value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const decibels = sliderToDecibels(value);
|
||||
return Math.pow(10, decibels / 20);
|
||||
}
|
||||
|
||||
function volumeToSlider(volume: number): number {
|
||||
if (volume <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const decibels = 20 * Math.log10(volume);
|
||||
if (!Number.isFinite(decibels)) {
|
||||
return 0;
|
||||
}
|
||||
const slider = (decibels - MIN_VOLUME_DB) / (0 - MIN_VOLUME_DB);
|
||||
return Math.max(0, Math.min(1, slider));
|
||||
}
|
||||
|
||||
function formatDecibels(decibels: number): string {
|
||||
if (!Number.isFinite(decibels) || decibels <= MIN_VOLUME_DB + 0.1) {
|
||||
return '-∞ dB';
|
||||
}
|
||||
return `${decibels.toFixed(1)} dB`;
|
||||
}
|
||||
|
||||
export default AudioPlayer;
|
||||
147
src/renderer/src/components/CategorySidebar.tsx
Normal file
147
src/renderer/src/components/CategorySidebar.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
|
||||
import { CATEGORY_FILTER_UNTAGGED, type CategoryFilterValue } from '../stores/LibraryStore';
|
||||
import {
|
||||
buildCategorySwatch,
|
||||
createCategoryStyleVars,
|
||||
formatCategoryLabel
|
||||
} from '../utils/categoryColors';
|
||||
|
||||
export interface CategorySidebarProps {
|
||||
categories: CategoryRecord[];
|
||||
files: AudioFileSummary[];
|
||||
activeFilter: CategoryFilterValue;
|
||||
onSelect(filter: CategoryFilterValue): void;
|
||||
onDropFiles?(fileIds: number[], categoryId: string): void;
|
||||
}
|
||||
|
||||
function deriveCounts(files: AudioFileSummary[]): {
|
||||
total: number;
|
||||
untagged: number;
|
||||
categoryCounts: Map<string, number>;
|
||||
} {
|
||||
const categoryCounts = new Map<string, number>();
|
||||
let untagged = 0;
|
||||
for (const file of files) {
|
||||
if (file.categories.length === 0) {
|
||||
untagged += 1;
|
||||
}
|
||||
for (const categoryId of file.categories) {
|
||||
categoryCounts.set(categoryId, (categoryCounts.get(categoryId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: files.length,
|
||||
untagged,
|
||||
categoryCounts
|
||||
};
|
||||
}
|
||||
|
||||
export function CategorySidebar({ categories, files, activeFilter, onSelect, onDropFiles }: CategorySidebarProps): JSX.Element {
|
||||
const { total, untagged, categoryCounts } = deriveCounts(files);
|
||||
const swatchMap = useMemo(() => {
|
||||
const map = new Map<string, ReturnType<typeof buildCategorySwatch>>();
|
||||
for (const record of categories) {
|
||||
const existing = map.get(record.category) ?? buildCategorySwatch(record.category);
|
||||
map.set(record.category, existing);
|
||||
map.set(record.id, existing);
|
||||
}
|
||||
return map;
|
||||
}, [categories]);
|
||||
const grouped = new Map<string, CategoryRecord[]>();
|
||||
for (const record of categories) {
|
||||
grouped.set(record.category, [...(grouped.get(record.category) ?? []), record]);
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(grouped.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const handleSelect = (filter: CategoryFilterValue) => {
|
||||
onSelect(filter);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
if (event.dataTransfer.types.includes('application/audiosort-file')) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent, categoryId: string) => {
|
||||
event.preventDefault();
|
||||
const data = event.dataTransfer.getData('application/audiosort-file');
|
||||
if (!data || !onDropFiles) return;
|
||||
|
||||
try {
|
||||
const { selectedIds } = JSON.parse(data) as { fileId: number; selectedIds: number[] };
|
||||
const fileIds = selectedIds.length > 0 ? selectedIds : [];
|
||||
if (fileIds.length > 0) {
|
||||
onDropFiles(fileIds, categoryId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse drag data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="category-sidebar">
|
||||
<div className="category-sidebar__section">
|
||||
<button
|
||||
type="button"
|
||||
className="category-sidebar__item"
|
||||
data-active={activeFilter === null}
|
||||
onClick={() => handleSelect(null)}
|
||||
>
|
||||
<span className="category-sidebar__label">All Files</span>
|
||||
<span className="category-sidebar__count">{total}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="category-sidebar__item"
|
||||
data-active={activeFilter === CATEGORY_FILTER_UNTAGGED}
|
||||
onClick={() => handleSelect(CATEGORY_FILTER_UNTAGGED)}
|
||||
>
|
||||
<span className="category-sidebar__label">TO TAG</span>
|
||||
<span className="category-sidebar__count">{untagged}</span>
|
||||
</button>
|
||||
</div>
|
||||
{sortedGroups.map(([groupName, records]) => {
|
||||
const sortedRecords = records
|
||||
.slice()
|
||||
.sort((a, b) => a.subCategory.localeCompare(b.subCategory, undefined, { sensitivity: 'base' }));
|
||||
const hasVisible = sortedRecords.some((record) => (categoryCounts.get(record.id) ?? 0) > 0);
|
||||
if (!hasVisible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="category-sidebar__section" key={groupName}>
|
||||
<div className="category-sidebar__group">{formatCategoryLabel(groupName)}</div>
|
||||
{sortedRecords.map((record) => {
|
||||
const count = categoryCounts.get(record.id) ?? 0;
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
const styleVars = createCategoryStyleVars(swatchMap.get(record.id));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={record.id}
|
||||
className="category-sidebar__item category-sidebar__item--child"
|
||||
data-active={activeFilter === record.id}
|
||||
onClick={() => handleSelect(record.id)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(event) => handleDrop(event, record.id)}
|
||||
style={styleVars}
|
||||
>
|
||||
<span className="category-sidebar__label">{formatCategoryLabel(record.subCategory)}</span>
|
||||
<span className="category-sidebar__count">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategorySidebar;
|
||||
284
src/renderer/src/components/DuplicateComparisonDialog.tsx
Normal file
284
src/renderer/src/components/DuplicateComparisonDialog.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AudioFileSummary } from '../../../shared/models';
|
||||
import Waveform from './Waveform';
|
||||
|
||||
type DifferenceFlags = {
|
||||
fileName: boolean;
|
||||
directory: boolean;
|
||||
sampleRate: boolean;
|
||||
bitDepth: boolean;
|
||||
duration: boolean;
|
||||
size: boolean;
|
||||
};
|
||||
|
||||
export interface DuplicateComparisonDialogProps {
|
||||
duplicateGroups: { checksum: string; files: AudioFileSummary[] }[];
|
||||
onKeepFile(fileIdToKeep: number, fileIdsToDelete: number[]): Promise<void>;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for comparing and managing duplicate files.
|
||||
*/
|
||||
export function DuplicateComparisonDialog({ duplicateGroups, onKeepFile, onClose }: DuplicateComparisonDialogProps): JSX.Element {
|
||||
const [currentGroupIndex, setCurrentGroupIndex] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [waveformUrls, setWaveformUrls] = useState<Record<number, string>>({});
|
||||
const waveformPoolRef = useRef<string[]>([]);
|
||||
|
||||
if (duplicateGroups.length === 0) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content duplicate-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>No Duplicates Found</h2>
|
||||
<p>No duplicate files were found in your library.</p>
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={onClose} className="primary-button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentGroup = duplicateGroups[currentGroupIndex];
|
||||
const referenceFile = currentGroup.files[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
waveformPoolRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
waveformPoolRef.current = [];
|
||||
setWaveformUrls({});
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadWaveforms = async () => {
|
||||
const next: Record<number, string> = {};
|
||||
await Promise.all(
|
||||
currentGroup.files.map(async (file) => {
|
||||
try {
|
||||
const payload = await window.api.getAudioBuffer(file.id);
|
||||
const blob = new Blob([payload.buffer], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
waveformPoolRef.current.push(url);
|
||||
next[file.id] = url;
|
||||
} catch (error) {
|
||||
console.warn('Failed to prepare waveform for duplicate file', file.id, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setWaveformUrls(next);
|
||||
} else {
|
||||
Object.values(next).forEach((url) => URL.revokeObjectURL(url));
|
||||
}
|
||||
};
|
||||
|
||||
void loadWaveforms();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
waveformPoolRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
waveformPoolRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
const differences = useMemo(() => {
|
||||
if (!referenceFile) {
|
||||
return {} as Record<number, DifferenceFlags>;
|
||||
}
|
||||
const referenceDirectory = getDirectory(referenceFile);
|
||||
return currentGroup.files.reduce((acc, file) => {
|
||||
const directory = getDirectory(file);
|
||||
acc[file.id] = {
|
||||
fileName: file.fileName !== referenceFile.fileName,
|
||||
directory: directory !== referenceDirectory,
|
||||
sampleRate: file.sampleRate !== referenceFile.sampleRate,
|
||||
bitDepth: file.bitDepth !== referenceFile.bitDepth,
|
||||
duration: file.durationMs !== referenceFile.durationMs,
|
||||
size: file.size !== referenceFile.size
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<number, DifferenceFlags>);
|
||||
}, [currentGroup.files, referenceFile]);
|
||||
|
||||
const handleKeep = async (file: AudioFileSummary) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const idsToDelete = currentGroup.files.filter((candidate) => candidate.id !== file.id).map((candidate) => candidate.id);
|
||||
await onKeepFile(file.id, idsToDelete);
|
||||
|
||||
if (currentGroupIndex < duplicateGroups.length - 1) {
|
||||
setCurrentGroupIndex((index) => index + 1);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to delete duplicates: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
if (currentGroupIndex < duplicateGroups.length - 1) {
|
||||
setCurrentGroupIndex(currentGroupIndex + 1);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevGroup = () => {
|
||||
if (currentGroupIndex > 0) {
|
||||
setCurrentGroupIndex(currentGroupIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextGroup = () => {
|
||||
if (currentGroupIndex < duplicateGroups.length - 1) {
|
||||
setCurrentGroupIndex(currentGroupIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content duplicate-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="duplicate-header">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button duplicate-nav-button"
|
||||
onClick={handlePrevGroup}
|
||||
disabled={busy || currentGroupIndex === 0}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<div className="duplicate-header-summary">
|
||||
<h2>Duplicate Group {currentGroupIndex + 1} / {duplicateGroups.length}</h2>
|
||||
<p>Checksum {currentGroup.checksum.slice(0, 10)}… · {currentGroup.files.length} files match</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button duplicate-nav-button"
|
||||
onClick={handleNextGroup}
|
||||
disabled={busy || currentGroupIndex === duplicateGroups.length - 1}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
<p className="duplicate-tip">Scroll to review every duplicate. Differences from the first file are highlighted.</p>
|
||||
<div className="duplicate-card-scroll">
|
||||
<div className="duplicate-card-list">
|
||||
{currentGroup.files.map((file) => {
|
||||
const directory = getDirectory(file);
|
||||
const diff = differences[file.id] ?? {};
|
||||
const waveformUrl = waveformUrls[file.id] ?? null;
|
||||
const durationSeconds = (file.durationMs ?? 0) / 1000;
|
||||
|
||||
return (
|
||||
<div key={file.id} className="duplicate-column duplicate-column--scroll">
|
||||
<div className="duplicate-card">
|
||||
<header className="duplicate-card-header">
|
||||
<div>
|
||||
<h3>{file.displayName}</h3>
|
||||
<p>{directory}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="duplicate-waveform">
|
||||
{waveformUrl ? (
|
||||
<Waveform
|
||||
audioUrl={waveformUrl}
|
||||
currentTime={0}
|
||||
duration={durationSeconds}
|
||||
className="duplicate-waveform-canvas"
|
||||
/>
|
||||
) : (
|
||||
<div className="duplicate-waveform-placeholder">Preparing waveform…</div>
|
||||
)}
|
||||
</div>
|
||||
<dl className="duplicate-details">
|
||||
<div className={diff.fileName ? 'duplicate-detail-row duplicate-detail-row--diff' : 'duplicate-detail-row'}>
|
||||
<dt>Filename</dt>
|
||||
<dd>{file.fileName}</dd>
|
||||
</div>
|
||||
<div className={diff.directory ? 'duplicate-detail-row duplicate-detail-row--diff' : 'duplicate-detail-row'}>
|
||||
<dt>Directory</dt>
|
||||
<dd>{directory}</dd>
|
||||
</div>
|
||||
<div className={diff.sampleRate ? 'duplicate-detail-row duplicate-detail-row--diff' : 'duplicate-detail-row'}>
|
||||
<dt>Sample Rate</dt>
|
||||
<dd>{file.sampleRate ? `${file.sampleRate} Hz` : 'Unknown'}</dd>
|
||||
</div>
|
||||
<div className={diff.bitDepth ? 'duplicate-detail-row duplicate-detail-row--diff' : 'duplicate-detail-row'}>
|
||||
<dt>Bit Depth</dt>
|
||||
<dd>{file.bitDepth ?? 'Unknown'}</dd>
|
||||
</div>
|
||||
<div className={diff.duration ? 'duplicate-detail-row duplicate-detail-row--diff' : 'duplicate-detail-row'}>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatDuration(file.durationMs)}</dd>
|
||||
</div>
|
||||
<div className={diff.size ? 'duplicate-detail-row duplicate-detail-row--diff' : 'duplicate-detail-row'}>
|
||||
<dt>Size</dt>
|
||||
<dd>{formatBytes(file.size)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleKeep(file)}
|
||||
disabled={busy}
|
||||
className="primary-button"
|
||||
>
|
||||
Keep This File
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={handleSkip} className="ghost-button" disabled={busy}>
|
||||
Skip This Group
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="ghost-button" disabled={busy}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDirectory(file: AudioFileSummary): string {
|
||||
return file.relativePath.replace(file.fileName, '') || '(root)';
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | null): string {
|
||||
if (!durationMs) {
|
||||
return 'Unknown';
|
||||
}
|
||||
const totalSeconds = durationMs / 1000;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
const milliseconds = Math.floor(durationMs % 1000);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export default DuplicateComparisonDialog;
|
||||
432
src/renderer/src/components/FileDetailPanel.tsx
Normal file
432
src/renderer/src/components/FileDetailPanel.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent } from 'react';
|
||||
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
|
||||
import { TagEditor } from './TagEditor';
|
||||
|
||||
/**
|
||||
* Props for FileDetailPanel component.
|
||||
*/
|
||||
export interface FileDetailPanelProps {
|
||||
file: AudioFileSummary | null;
|
||||
categories: CategoryRecord[];
|
||||
onRename(newName: string): Promise<void>;
|
||||
onMove(targetRelativeDirectory: string): Promise<void>;
|
||||
/** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */
|
||||
onOrganize(metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
||||
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
|
||||
onUpdateCustomName(customName: string | null): Promise<void>;
|
||||
metadataSuggestionsVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays metadata for the selected file with rename, move, and tagging controls.
|
||||
*/
|
||||
export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize, onUpdateTags, onUpdateCustomName, metadataSuggestionsVersion }: FileDetailPanelProps): JSX.Element {
|
||||
const [isEditingCustomName, setIsEditingCustomName] = useState(false);
|
||||
const [moveDraft, setMoveDraft] = useState('');
|
||||
const [customNameDraft, setCustomNameDraft] = useState('');
|
||||
const [authorDraft, setAuthorDraft] = useState('');
|
||||
const [ratingDraft, setRatingDraft] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
||||
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
||||
const initialMetadataRef = useRef<{ author: string; copyright: string; rating: number; customName: string }>({
|
||||
author: '',
|
||||
copyright: '',
|
||||
rating: 0,
|
||||
customName: ''
|
||||
});
|
||||
|
||||
const appendSuggestion = useCallback((value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
setSuggestions((previous) => {
|
||||
if (previous.authors.some((entry) => entry.localeCompare(trimmed, undefined, { sensitivity: 'accent' }) === 0)) {
|
||||
return previous;
|
||||
}
|
||||
const nextList = [...previous.authors, trimmed].sort((a, b) => a.localeCompare(b));
|
||||
return {
|
||||
authors: nextList
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteredAuthorSuggestions = useMemo(() => {
|
||||
if (suggestions.authors.length === 0) {
|
||||
return [] as string[];
|
||||
}
|
||||
const query = authorDraft.trim().toLowerCase();
|
||||
const base = suggestions.authors.filter((entry) => entry.toLowerCase() !== query);
|
||||
if (query.length === 0) {
|
||||
return base.slice(0, 5);
|
||||
}
|
||||
return base.filter((entry) => entry.toLowerCase().includes(query)).slice(0, 5);
|
||||
}, [authorDraft, suggestions.authors]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSuggestions({ authors: [] });
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await window.api.listMetadataSuggestions();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setSuggestions({
|
||||
authors: result.authors
|
||||
});
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to load metadata suggestions:', error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [metadataSuggestionsVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setMoveDraft('');
|
||||
setCustomNameDraft('');
|
||||
setAuthorDraft('');
|
||||
setRatingDraft(0);
|
||||
setIsEditingCustomName(false);
|
||||
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: '' };
|
||||
return;
|
||||
}
|
||||
|
||||
setMoveDraft(getParentDirectory(file.relativePath));
|
||||
setCustomNameDraft(file.customName ?? '');
|
||||
setIsEditingCustomName(false);
|
||||
const baseCustomName = (file.customName ?? '').trim();
|
||||
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const metadata = await window.api.readFileMetadata(file.id);
|
||||
let customNameValue = baseCustomName;
|
||||
if (metadata.title && metadata.title.trim().length > 0) {
|
||||
customNameValue = metadata.title.trim();
|
||||
setCustomNameDraft(customNameValue);
|
||||
}
|
||||
const authorValue = metadata.author?.trim() ?? '';
|
||||
const ratingValue = metadata.rating ?? 0;
|
||||
setAuthorDraft(authorValue);
|
||||
setRatingDraft(ratingValue);
|
||||
initialMetadataRef.current = {
|
||||
author: authorValue,
|
||||
copyright: '',
|
||||
rating: ratingValue,
|
||||
customName: customNameValue
|
||||
};
|
||||
if (authorValue.length > 0) {
|
||||
appendSuggestion(authorValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to read file metadata:', error);
|
||||
setAuthorDraft('');
|
||||
setRatingDraft(0);
|
||||
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
||||
}
|
||||
})();
|
||||
}, [appendSuggestion, file?.id]);
|
||||
|
||||
if (!file) {
|
||||
return <section className="file-detail empty">Select a file to view its details.</section>;
|
||||
}
|
||||
|
||||
const handleStartCustomNameEdit = () => {
|
||||
setIsEditingCustomName(true);
|
||||
};
|
||||
|
||||
const handleCustomNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === 'Escape') {
|
||||
setIsEditingCustomName(false);
|
||||
if (file) {
|
||||
setCustomNameDraft(file.customName ?? '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
await onMove(moveDraft.trim());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomNameBlur = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setIsEditingCustomName(false);
|
||||
|
||||
const normalizedValue = customNameDraft.trim();
|
||||
const previousValue = file.customName?.trim() ?? '';
|
||||
if (normalizedValue === previousValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = normalizedValue.length > 0 ? normalizedValue : null;
|
||||
let updateSucceeded = false;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onUpdateCustomName(payload);
|
||||
updateSucceeded = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update custom name:', error);
|
||||
setCustomNameDraft(file.customName ?? '');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
if (updateSucceeded) {
|
||||
await triggerOrganize({ customName: normalizedValue });
|
||||
}
|
||||
};
|
||||
|
||||
const triggerOrganize = async (overrides?: { author?: string | null; rating?: number; customName?: string | null }) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAuthorRaw = overrides?.author !== undefined ? overrides.author : authorDraft;
|
||||
const nextRatingValue = overrides?.rating !== undefined ? overrides.rating : ratingDraft;
|
||||
const nextCustomNameRaw =
|
||||
overrides?.customName !== undefined ? overrides.customName ?? '' : customNameDraft;
|
||||
|
||||
const trimmedAuthor = (nextAuthorRaw ?? '').trim();
|
||||
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
|
||||
const comparisonState = {
|
||||
author: trimmedAuthor,
|
||||
copyright: '',
|
||||
rating: nextRatingValue,
|
||||
customName: trimmedCustomName
|
||||
};
|
||||
|
||||
const previous = initialMetadataRef.current;
|
||||
if (
|
||||
previous &&
|
||||
previous.author === comparisonState.author &&
|
||||
previous.copyright === comparisonState.copyright &&
|
||||
previous.rating === comparisonState.rating &&
|
||||
previous.customName === comparisonState.customName
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const authorPayload = trimmedAuthor.length > 0 ? trimmedAuthor : null;
|
||||
if (file.categories.length === 0) {
|
||||
// No categories - just save metadata without organizing
|
||||
await window.api.updateFileMetadata(file.id, {
|
||||
author: authorPayload,
|
||||
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
||||
});
|
||||
} else {
|
||||
// Has categories - organize the file
|
||||
await onOrganize({
|
||||
customName: trimmedCustomName.length > 0 ? trimmedCustomName : null,
|
||||
author: authorPayload,
|
||||
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
||||
});
|
||||
}
|
||||
|
||||
initialMetadataRef.current = comparisonState;
|
||||
if (trimmedAuthor.length > 0) {
|
||||
appendSuggestion(trimmedAuthor);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save metadata:', error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagSave = async (data: { tags: string[]; categories: string[] }) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onUpdateTags(data);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
if (!file) return;
|
||||
try {
|
||||
await window.api.openFileFolder(file.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to open folder:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTagSection = () => {
|
||||
setIsTagSectionExpanded((value) => !value);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="file-detail">
|
||||
<header className="file-detail-header">
|
||||
<div>
|
||||
{isEditingCustomName ? (
|
||||
<input
|
||||
className="file-name-input"
|
||||
value={customNameDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setCustomNameDraft(event.target.value)}
|
||||
onBlur={handleCustomNameBlur}
|
||||
onKeyDown={handleCustomNameKeyDown}
|
||||
disabled={busy}
|
||||
placeholder="Enter custom name"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h1 className="file-name-editable" onClick={handleStartCustomNameEdit} title="Click to edit name">
|
||||
{file.customName || file.displayName}
|
||||
</h1>
|
||||
)}
|
||||
<p
|
||||
onClick={handleOpenFolder}
|
||||
style={{ cursor: 'pointer', opacity: 0.7, transition: 'opacity 0.2s ease' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.7'}
|
||||
title="Click to open in folder"
|
||||
>
|
||||
{file.relativePath}
|
||||
</p>
|
||||
</div>
|
||||
<div className="file-meta-grid">
|
||||
<div>{formatBytes(file.size)}</div>
|
||||
<div>{formatDuration(file.durationMs)}</div>
|
||||
<div>{file.sampleRate ? `${(file.sampleRate / 1000).toFixed(1)}kHz` : 'Unknown'}</div>
|
||||
<div>{file.bitDepth ? `${file.bitDepth}-bit` : 'Unknown'}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form className="file-actions" onSubmit={(event) => event.preventDefault()}>
|
||||
<div className="file-actions-grid">
|
||||
<label>
|
||||
<span>Author</span>
|
||||
<input
|
||||
value={authorDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setAuthorDraft(event.target.value)}
|
||||
onBlur={(event) => {
|
||||
void triggerOrganize({ author: event.currentTarget.value });
|
||||
}}
|
||||
placeholder="Artist or creator name"
|
||||
disabled={busy}
|
||||
/>
|
||||
{filteredAuthorSuggestions.length > 0 && (
|
||||
<div className="metadata-suggestion-list" role="listbox" aria-label="Author suggestions">
|
||||
{filteredAuthorSuggestions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className="metadata-suggestion"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setAuthorDraft(option);
|
||||
void triggerOrganize({ author: option });
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span>Rating</span>
|
||||
<div className="star-rating">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
className={ratingDraft >= star ? 'star star--filled' : 'star'}
|
||||
onClick={() => {
|
||||
const nextValue = ratingDraft === star ? 0 : star;
|
||||
setRatingDraft(nextValue);
|
||||
void triggerOrganize({ rating: nextValue });
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={`${star} star${star > 1 ? 's' : ''}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="tag-editor-pane">
|
||||
<button
|
||||
type="button"
|
||||
className="tag-editor-toggle"
|
||||
onClick={toggleTagSection}
|
||||
aria-expanded={isTagSectionExpanded}
|
||||
>
|
||||
<span className="tag-editor-toggle-label">Tags</span>
|
||||
<span className="tag-editor-toggle-icon" aria-hidden="true">
|
||||
{isTagSectionExpanded ? 'v' : '>'}
|
||||
</span>
|
||||
</button>
|
||||
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
|
||||
<TagEditor
|
||||
tags={file.tags}
|
||||
categories={file.categories}
|
||||
availableCategories={categories}
|
||||
onSave={handleTagSave}
|
||||
showHeading={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | null): string {
|
||||
if (!durationMs) {
|
||||
return 'Unknown';
|
||||
}
|
||||
const totalSeconds = durationMs / 1000;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
const milliseconds = Math.floor(durationMs % 1000);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function getParentDirectory(relativePath: string): string {
|
||||
if (!relativePath || !relativePath.includes(pathSeparator())) {
|
||||
return '';
|
||||
}
|
||||
const segments = relativePath.split(pathSeparator());
|
||||
segments.pop();
|
||||
return segments.join(pathSeparator());
|
||||
}
|
||||
|
||||
function pathSeparator(): string {
|
||||
return typeof window !== 'undefined' && navigator.platform.startsWith('Win') ? '\\' : '/';
|
||||
}
|
||||
|
||||
export default FileDetailPanel;
|
||||
582
src/renderer/src/components/FileList.tsx
Normal file
582
src/renderer/src/components/FileList.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import { useEffect, useReducer, useRef } from 'react';
|
||||
import type { AudioFileSummary } from '../../../shared/models';
|
||||
|
||||
export interface FileListProps {
|
||||
files: AudioFileSummary[];
|
||||
selectedId: number | null;
|
||||
selectedIds: Set<number>;
|
||||
onSelect(fileId: number, options: { multi?: boolean; range?: boolean }): void;
|
||||
onPlay?(file: AudioFileSummary): void;
|
||||
searchValue: string;
|
||||
onSearchChange(value: string): void;
|
||||
/**
|
||||
* Invoked when the user requests to select every visible file (Ctrl+A).
|
||||
*/
|
||||
onSelectAll?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical list of WAV files with highlighting for the active selection.
|
||||
*/
|
||||
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange, onSelectAll }: FileListProps): JSX.Element {
|
||||
const buttonRefs = useRef(new Map<number, HTMLButtonElement>());
|
||||
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
|
||||
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null);
|
||||
const dragRotation = useRef<number>(0);
|
||||
const lastDragX = useRef<number>(0);
|
||||
const dropTargetPosition = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragOffset = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const dragStartTime = useRef<number>(0);
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === null) {
|
||||
return;
|
||||
}
|
||||
const button = buttonRefs.current.get(selectedId);
|
||||
if (!button || document.activeElement === button) {
|
||||
return;
|
||||
}
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
const isTypingContext = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement;
|
||||
const isEditable = activeElement?.getAttribute?.('contenteditable') === 'true';
|
||||
if (isTypingContext || isEditable) {
|
||||
return;
|
||||
}
|
||||
// Keep keyboard focus aligned with the active selection.
|
||||
button.focus();
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.api?.getWaveformPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleIds = new Set(files.map((file) => file.id));
|
||||
for (const cachedId of Object.keys(waveformCacheRef.current)) {
|
||||
const numericId = Number.parseInt(cachedId, 10);
|
||||
if (!visibleIds.has(numericId)) {
|
||||
delete waveformCacheRef.current[numericId];
|
||||
}
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadPreviews = async () => {
|
||||
for (const file of files) {
|
||||
if (waveformCacheRef.current[file.id]) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const preview = await window.api.getWaveformPreview(file.id, WAVEFORM_POINT_COUNT);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, preview.samples, preview.rms);
|
||||
forceWaveformUpdate();
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
console.error(`Failed to load waveform preview for file ${file.id} (${file.fileName}):`, error);
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
waveformCacheRef.current[file.id] = buildWaveformVisual(seed, null, null);
|
||||
forceWaveformUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadPreviews();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSelectAll) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'a' && event.key !== 'A') {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
const isTypingContext = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement;
|
||||
const isEditable = activeElement.getAttribute('contenteditable') === 'true';
|
||||
if (isTypingContext || isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (listContainerRef.current && !listContainerRef.current.contains(activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onSelectAll();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onSelectAll]);
|
||||
|
||||
const handleClick = (fileId: number, event: React.MouseEvent) => {
|
||||
onSelect(fileId, {
|
||||
multi: event.ctrlKey || event.metaKey,
|
||||
range: event.shiftKey
|
||||
});
|
||||
};
|
||||
|
||||
const handleDoubleClick = (file: AudioFileSummary) => {
|
||||
if (onPlay) {
|
||||
onPlay(file);
|
||||
}
|
||||
};
|
||||
|
||||
const stripExtension = (filename: string): string => {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
return lastDot > 0 ? filename.slice(0, lastDot) : filename;
|
||||
};
|
||||
|
||||
const formatTooltip = (file: AudioFileSummary): string => {
|
||||
const customLabel = file.customName?.trim();
|
||||
const title = customLabel && customLabel.length > 0 ? customLabel : file.displayName;
|
||||
const sampleRate = file.sampleRate ? `${(file.sampleRate / 1000).toFixed(1)}kHz` : 'Unknown';
|
||||
const bitDepth = file.bitDepth ? `${file.bitDepth}-bit` : 'Unknown';
|
||||
const size = formatBytes(file.size);
|
||||
return `${title}\n${sampleRate} • ${bitDepth} • ${size}\n${file.relativePath}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="file-list">
|
||||
<div className="file-list-search">
|
||||
<input
|
||||
className="search-input"
|
||||
value={searchValue}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search files..."
|
||||
/>
|
||||
</div>
|
||||
<div className="file-list-items" ref={listContainerRef}>
|
||||
{files.map((file) => {
|
||||
const isActive = file.id === selectedId;
|
||||
const isSelected = selectedIds.has(file.id);
|
||||
const className = isActive
|
||||
? 'file-item file-item--active'
|
||||
: isSelected
|
||||
? 'file-item file-item--selected'
|
||||
: 'file-item';
|
||||
const customLabel = file.customName?.trim();
|
||||
const primaryLabel = customLabel && customLabel.length > 0 ? customLabel : file.displayName;
|
||||
const seed = file.checksum ?? file.absolutePath;
|
||||
const gradientId = `waveGradient-${file.id}`;
|
||||
const wave = waveformCacheRef.current[file.id] ?? buildWaveformVisual(seed, null, null);
|
||||
return (
|
||||
<button
|
||||
key={file.id}
|
||||
type="button"
|
||||
className={className}
|
||||
draggable={true}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
buttonRefs.current.set(file.id, node);
|
||||
} else {
|
||||
buttonRefs.current.delete(file.id);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => handleClick(file.id, event)}
|
||||
onDoubleClick={() => handleDoubleClick(file)}
|
||||
onDragStart={(event) => {
|
||||
// If dragging an unselected item, only drag that item
|
||||
// If dragging a selected item, drag all selected items
|
||||
const draggedIds = selectedIds.has(file.id) ? Array.from(selectedIds) : [file.id];
|
||||
|
||||
event.dataTransfer.setData('application/audiosort-file', JSON.stringify({
|
||||
fileId: file.id,
|
||||
selectedIds: draggedIds
|
||||
}));
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// Create transparent drag image to hide the default
|
||||
const transparent = document.createElement('div');
|
||||
transparent.style.width = '1px';
|
||||
transparent.style.height = '1px';
|
||||
transparent.style.opacity = '0';
|
||||
document.body.appendChild(transparent);
|
||||
event.dataTransfer.setDragImage(transparent, 0, 0);
|
||||
setTimeout(() => transparent.remove(), 0);
|
||||
|
||||
// Calculate offset from click position to element center
|
||||
const sourceElement = event.currentTarget as HTMLElement;
|
||||
const rect = sourceElement.getBoundingClientRect();
|
||||
const elementCenterX = rect.left + rect.width / 2;
|
||||
const elementCenterY = rect.top + rect.height / 2;
|
||||
dragOffset.current = {
|
||||
x: elementCenterX - event.clientX,
|
||||
y: elementCenterY - event.clientY
|
||||
};
|
||||
|
||||
// Calculate transition duration based on offset distance
|
||||
const offsetDistance = Math.sqrt(
|
||||
dragOffset.current.x * dragOffset.current.x +
|
||||
dragOffset.current.y * dragOffset.current.y
|
||||
);
|
||||
const maxDistance = Math.sqrt(rect.width * rect.width + rect.height * rect.height) / 2;
|
||||
const normalizedDistance = Math.min(offsetDistance / maxDistance, 1);
|
||||
const transitionDuration = 0.1 + normalizedDistance * 0.2; // 0.1s to 0.3s
|
||||
|
||||
dragStartTime.current = Date.now();
|
||||
|
||||
// Clone the current element for custom floating preview
|
||||
const ghost = sourceElement.cloneNode(true) as HTMLElement;
|
||||
|
||||
// Create a wrapper for rotation that doesn't affect position transition
|
||||
const rotationWrapper = document.createElement('div');
|
||||
rotationWrapper.style.position = 'fixed';
|
||||
rotationWrapper.style.top = `${event.clientY}px`;
|
||||
rotationWrapper.style.left = `${event.clientX}px`;
|
||||
rotationWrapper.style.width = '0';
|
||||
rotationWrapper.style.height = '0';
|
||||
rotationWrapper.style.pointerEvents = 'none';
|
||||
rotationWrapper.style.zIndex = '10000';
|
||||
rotationWrapper.style.willChange = 'transform';
|
||||
|
||||
ghost.style.position = 'absolute';
|
||||
ghost.style.top = '0';
|
||||
ghost.style.left = '0';
|
||||
ghost.style.width = `${sourceElement.offsetWidth}px`;
|
||||
ghost.style.height = `${sourceElement.offsetHeight}px`;
|
||||
ghost.style.opacity = '0.9';
|
||||
ghost.style.contain = 'layout style paint';
|
||||
ghost.style.transform = `translate(${-rect.width / 2 + dragOffset.current.x}px, ${-rect.height / 2 + dragOffset.current.y}px)`;
|
||||
ghost.style.transition = `transform ${transitionDuration}s cubic-bezier(0.25, 0.1, 0.25, 1)`;
|
||||
|
||||
rotationWrapper.appendChild(ghost);
|
||||
document.body.appendChild(rotationWrapper);
|
||||
|
||||
// Trigger the offset transition to center
|
||||
requestAnimationFrame(() => {
|
||||
if (ghost.parentNode) {
|
||||
ghost.style.transform = `translate(-50%, -50%)`;
|
||||
}
|
||||
});
|
||||
|
||||
dragGhostRef.current = rotationWrapper;
|
||||
lastDragX.current = event.clientX;
|
||||
dragRotation.current = 0;
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!dragGhostRef.current || (event.clientX === 0 && event.clientY === 0)) return;
|
||||
|
||||
const deltaX = event.clientX - lastDragX.current;
|
||||
lastDragX.current = event.clientX;
|
||||
|
||||
const impulse = deltaX * 0.9;
|
||||
const blended = dragRotation.current * 0.75 + impulse;
|
||||
const clamped = Math.max(-12, Math.min(12, blended));
|
||||
dragRotation.current = clamped;
|
||||
|
||||
// Update wrapper position (no rotation here)
|
||||
dragGhostRef.current.style.top = `${event.clientY}px`;
|
||||
dragGhostRef.current.style.left = `${event.clientX}px`;
|
||||
|
||||
// Apply rotation directly to wrapper without transition
|
||||
dragGhostRef.current.style.transform = `rotate(${clamped}deg)`;
|
||||
|
||||
// Track position for potential drop animation
|
||||
dropTargetPosition.current = { x: event.clientX, y: event.clientY };
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
const wrapper = dragGhostRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
// Check if this was a successful drop (dropEffect will be 'copy' if accepted)
|
||||
const wasDropped = event.dataTransfer.dropEffect === 'copy';
|
||||
|
||||
if (wasDropped && dropTargetPosition.current) {
|
||||
// Animate shrink into drop position
|
||||
wrapper.style.transition = 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
wrapper.style.transform = `rotate(0deg) scale(0)`;
|
||||
wrapper.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
wrapper.remove();
|
||||
dragGhostRef.current = null;
|
||||
}, 300);
|
||||
} else {
|
||||
// No drop or cancelled - remove immediately
|
||||
wrapper.remove();
|
||||
dragGhostRef.current = null;
|
||||
}
|
||||
|
||||
dragRotation.current = 0;
|
||||
dropTargetPosition.current = null;
|
||||
}}
|
||||
title={formatTooltip(file)}
|
||||
>
|
||||
<svg
|
||||
className="file-waveform"
|
||||
viewBox={`0 0 ${WAVEFORM_WIDTH} ${WAVEFORM_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={wave.gradientStart} />
|
||||
<stop offset="100%" stopColor={wave.gradientEnd} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={wave.path} fill={`url(#${gradientId})`} stroke={wave.stroke} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="file-item-content">
|
||||
<span className="file-label">
|
||||
<span className="file-label-primary">{primaryLabel}</span>
|
||||
<span className="file-label-secondary">{stripExtension(file.fileName)}</span>
|
||||
</span>
|
||||
<span className="file-meta">{formatDuration(file.durationMs)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{files.length === 0 && <div className="file-empty">No files matched the current filters.</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const WAVEFORM_WIDTH = 240;
|
||||
const WAVEFORM_HEIGHT = 68;
|
||||
const WAVEFORM_POINT_COUNT = 160;
|
||||
|
||||
const RMS_DB_MIN = -24;
|
||||
const RMS_DB_MAX = 0;
|
||||
const MIN_RMS_VALUE = 1e-4;
|
||||
|
||||
const RMS_COLOR_STOPS: ReadonlyArray<{ value: number; hue: number; saturation: number; lightness: number }> = [
|
||||
{ value: 0, hue: 210, saturation: 70, lightness: 56 },
|
||||
{ value: 0.33, hue: 150, saturation: 72, lightness: 52 },
|
||||
{ value: 0.66, hue: 48, saturation: 76, lightness: 50 },
|
||||
{ value: 1, hue: 8, saturation: 82, lightness: 46 }
|
||||
] as const;
|
||||
|
||||
interface WaveformVisual {
|
||||
path: string;
|
||||
stroke: string;
|
||||
gradientStart: string;
|
||||
gradientEnd: string;
|
||||
rms: number;
|
||||
}
|
||||
|
||||
function buildWaveformVisual(seedValue: string, samples: number[] | null, rms: number | null): WaveformVisual {
|
||||
const dataset = samples && samples.length > 1 ? samples : createFallbackSamples(seedValue);
|
||||
const resolvedRms = clampRms(typeof rms === 'number' ? rms : computeRms(dataset));
|
||||
const colorLevel = mapRmsToColorLevel(resolvedRms);
|
||||
const palette = deriveWaveformPalette(seedValue, colorLevel);
|
||||
const path = buildWaveformPath(dataset);
|
||||
return {
|
||||
path,
|
||||
stroke: palette.stroke,
|
||||
gradientStart: palette.gradientStart,
|
||||
gradientEnd: palette.gradientEnd,
|
||||
rms: resolvedRms
|
||||
};
|
||||
}
|
||||
|
||||
function buildWaveformPath(samples: number[]): string {
|
||||
if (samples.length === 0) {
|
||||
const baseline = WAVEFORM_HEIGHT / 2;
|
||||
return `M 0 ${baseline.toFixed(2)} L ${WAVEFORM_WIDTH.toFixed(2)} ${baseline.toFixed(2)}`;
|
||||
}
|
||||
|
||||
const baseline = WAVEFORM_HEIGHT / 2;
|
||||
const amplitudeScale = WAVEFORM_HEIGHT * 0.48;
|
||||
const step = samples.length > 1 ? WAVEFORM_WIDTH / (samples.length - 1) : WAVEFORM_WIDTH;
|
||||
const topSegments: string[] = [];
|
||||
const bottomSegments: string[] = [];
|
||||
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const sample = Math.max(0, Math.min(1, samples[index] ?? 0));
|
||||
const x = step * index;
|
||||
const amplitude = sample * amplitudeScale;
|
||||
const topY = baseline - amplitude;
|
||||
const bottomY = baseline + amplitude;
|
||||
topSegments.push(`L ${x.toFixed(2)} ${topY.toFixed(2)}`);
|
||||
bottomSegments.push(`L ${x.toFixed(2)} ${bottomY.toFixed(2)}`);
|
||||
}
|
||||
|
||||
let path = `M 0 ${baseline.toFixed(2)}`;
|
||||
if (topSegments.length > 0) {
|
||||
path += ` ${topSegments.join(' ')}`;
|
||||
}
|
||||
if (bottomSegments.length > 0) {
|
||||
for (let index = bottomSegments.length - 1; index >= 0; index -= 1) {
|
||||
path += ` ${bottomSegments[index]}`;
|
||||
}
|
||||
}
|
||||
path += ' Z';
|
||||
return path;
|
||||
}
|
||||
|
||||
function createFallbackSamples(seedValue: string): number[] {
|
||||
const rng = mulberry32(hashString(`${seedValue}-fallback`));
|
||||
const samples: number[] = [];
|
||||
for (let index = 0; index < WAVEFORM_POINT_COUNT; index += 1) {
|
||||
const t = index / Math.max(WAVEFORM_POINT_COUNT - 1, 1);
|
||||
const envelope = Math.sin(t * Math.PI);
|
||||
const jitter = (rng() - 0.5) * 0.25;
|
||||
const value = Math.min(1, Math.max(0, 0.18 + envelope * (0.55 + jitter)));
|
||||
samples.push(value);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
function deriveWaveformPalette(seedValue: string, level: number): Pick<WaveformVisual, 'gradientStart' | 'gradientEnd' | 'stroke'> {
|
||||
const normalized = clamp(level, 0, 1);
|
||||
const baseColor = interpolateColorStops(RMS_COLOR_STOPS, normalized);
|
||||
const jitterSeed = hashString(`${seedValue}-palette`);
|
||||
const hueJitter = ((jitterSeed % 19) - 9) * 0.6;
|
||||
const saturationJitter = (((jitterSeed >> 4) % 17) - 8) * 0.5;
|
||||
const lightnessJitter = (((jitterSeed >> 9) % 15) - 7) * 0.6;
|
||||
|
||||
const baseHue = wrapHue(baseColor.hue + hueJitter);
|
||||
const saturation = clamp(baseColor.saturation + saturationJitter, 42, 90);
|
||||
const baseLightness = clamp(baseColor.lightness + lightnessJitter, 28, 64);
|
||||
const accentHue = wrapHue(baseHue + 18);
|
||||
const strokeHue = wrapHue(baseHue + 8);
|
||||
const gradientStartLightness = clamp(baseLightness + 6, 32, 74);
|
||||
const gradientEndLightness = clamp(baseLightness - 6, 20, 60);
|
||||
const strokeLightness = clamp(baseLightness - 10, 18, 58);
|
||||
const strokeAlpha = clamp(0.44 + normalized * 0.4, 0.32, 0.86);
|
||||
|
||||
return {
|
||||
gradientStart: `hsla(${Math.round(baseHue)}, ${Math.round(saturation)}%, ${Math.round(gradientStartLightness)}%, 0.32)`,
|
||||
gradientEnd: `hsla(${Math.round(accentHue)}, ${Math.round(saturation + 6)}%, ${Math.round(gradientEndLightness)}%, 0.24)`,
|
||||
stroke: `hsla(${Math.round(strokeHue)}, ${Math.round(saturation + 4)}%, ${Math.round(strokeLightness)}%, ${strokeAlpha.toFixed(2)})`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an RMS value into a 0..1 interpolation level using a decibel scale.
|
||||
*/
|
||||
function mapRmsToColorLevel(rms: number): number {
|
||||
if (!Number.isFinite(rms) || rms <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const safeRms = clamp(rms, MIN_RMS_VALUE, 1);
|
||||
const db = 20 * Math.log10(safeRms);
|
||||
const clampedDb = clamp(db, RMS_DB_MIN, RMS_DB_MAX);
|
||||
return (clampedDb - RMS_DB_MIN) / (RMS_DB_MAX - RMS_DB_MIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs linear interpolation across color stops for the provided interpolation value (0..1).
|
||||
*/
|
||||
function interpolateColorStops(
|
||||
stops: ReadonlyArray<{ value: number; hue: number; saturation: number; lightness: number }>,
|
||||
value: number
|
||||
): { hue: number; saturation: number; lightness: number } {
|
||||
if (stops.length === 0) {
|
||||
return { hue: 0, saturation: 60, lightness: 50 };
|
||||
}
|
||||
if (value <= stops[0].value) {
|
||||
const { hue, saturation, lightness } = stops[0];
|
||||
return { hue, saturation, lightness };
|
||||
}
|
||||
if (value >= stops[stops.length - 1].value) {
|
||||
const { hue, saturation, lightness } = stops[stops.length - 1];
|
||||
return { hue, saturation, lightness };
|
||||
}
|
||||
|
||||
for (let index = 0; index < stops.length - 1; index += 1) {
|
||||
const start = stops[index];
|
||||
const end = stops[index + 1];
|
||||
if (value >= start.value && value <= end.value) {
|
||||
const range = end.value - start.value;
|
||||
const t = range > 0 ? (value - start.value) / range : 0;
|
||||
return {
|
||||
hue: start.hue + (end.hue - start.hue) * t,
|
||||
saturation: start.saturation + (end.saturation - start.saturation) * t,
|
||||
lightness: start.lightness + (end.lightness - start.lightness) * t
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { hue, saturation, lightness } = stops[stops.length - 1];
|
||||
return { hue, saturation, lightness };
|
||||
}
|
||||
|
||||
function computeRms(samples: number[]): number {
|
||||
if (samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let sumSquares = 0;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const value = clamp(samples[index] ?? 0, 0, 1);
|
||||
sumSquares += value * value;
|
||||
}
|
||||
return Math.sqrt(sumSquares / samples.length);
|
||||
}
|
||||
|
||||
function clampRms(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return clamp(value, 0, 1);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function wrapHue(value: number): number {
|
||||
const modulo = value % 360;
|
||||
return modulo < 0 ? modulo + 360 : modulo;
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
return () => {
|
||||
let t = (seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | null): string {
|
||||
if (!durationMs || Number.isNaN(durationMs)) {
|
||||
return '–';
|
||||
}
|
||||
const seconds = Math.round(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${minutes}:${remaining.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default FileList;
|
||||
410
src/renderer/src/components/MultiFileEditor.tsx
Normal file
410
src/renderer/src/components/MultiFileEditor.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useState, useEffect, useRef, useLayoutEffect, useMemo, useCallback, type UIEvent } from 'react';
|
||||
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
|
||||
import { getMultiEditorScrollTop, setMultiEditorScrollTop } from '../stores/CategoryUIState';
|
||||
import { TagEditor } from './TagEditor';
|
||||
|
||||
export interface MultiFileEditorProps {
|
||||
files: AudioFileSummary[];
|
||||
categories: CategoryRecord[];
|
||||
onUpdateTags(fileId: number, data: { tags: string[]; categories: string[] }): Promise<void>;
|
||||
onUpdateCustomName(fileId: number, customName: string | null): Promise<void>;
|
||||
onOrganize(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
||||
onUpdateMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
||||
metadataSuggestionsVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-file tag editor that shows aggregated metadata and allows batch editing.
|
||||
*/
|
||||
export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCustomName, onOrganize, onUpdateMetadata, metadataSuggestionsVersion }: MultiFileEditorProps): JSX.Element {
|
||||
const [sharedCustomName, setSharedCustomName] = useState('');
|
||||
const [sharedAuthor, setSharedAuthor] = useState('');
|
||||
const [sharedCopyright, setSharedCopyright] = useState('');
|
||||
const [sharedRating, setSharedRating] = useState(0);
|
||||
const [suggestions, setSuggestions] = useState<{ authors: string[]; copyrights: string[] }>({ authors: [], copyrights: [] });
|
||||
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
||||
|
||||
// Compute aggregated tags and categories for TagEditor
|
||||
const aggregatedTags = useMemo(() => {
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const file of files) {
|
||||
for (const tag of file.tags) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
}
|
||||
}
|
||||
// Only show tags that appear in all files
|
||||
return Array.from(tagCounts.entries())
|
||||
.filter(([, count]) => count === files.length)
|
||||
.map(([tag]) => tag);
|
||||
}, [files]);
|
||||
|
||||
const aggregatedCategories = useMemo(() => {
|
||||
const categoryCounts = new Map<string, number>();
|
||||
for (const file of files) {
|
||||
for (const category of file.categories) {
|
||||
categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1);
|
||||
}
|
||||
}
|
||||
// Only show categories that appear in all files
|
||||
return Array.from(categoryCounts.entries())
|
||||
.filter(([, count]) => count === files.length)
|
||||
.map(([category]) => category);
|
||||
}, [files]);
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
type SuggestionKey = 'authors' | 'copyrights';
|
||||
|
||||
const appendSuggestion = useCallback((value: string, key: SuggestionKey) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
setSuggestions((previous) => {
|
||||
if (previous[key].some((entry) => entry.localeCompare(trimmed, undefined, { sensitivity: 'accent' }) === 0)) {
|
||||
return previous;
|
||||
}
|
||||
const nextList = [...previous[key], trimmed].sort((a, b) => a.localeCompare(b));
|
||||
return {
|
||||
...previous,
|
||||
[key]: nextList
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteredAuthorSuggestions = useMemo(() => {
|
||||
if (suggestions.authors.length === 0) {
|
||||
return [] as string[];
|
||||
}
|
||||
const query = sharedAuthor.trim().toLowerCase();
|
||||
const base = suggestions.authors.filter((entry) => entry.toLowerCase() !== query);
|
||||
if (query.length === 0) {
|
||||
return base.slice(0, 5);
|
||||
}
|
||||
return base.filter((entry) => entry.toLowerCase().includes(query)).slice(0, 5);
|
||||
}, [sharedAuthor, suggestions.authors]);
|
||||
|
||||
const filteredCopyrightSuggestions = useMemo(() => {
|
||||
if (suggestions.copyrights.length === 0) {
|
||||
return [] as string[];
|
||||
}
|
||||
const query = sharedCopyright.trim().toLowerCase();
|
||||
const base = suggestions.copyrights.filter((entry) => entry.toLowerCase() !== query);
|
||||
if (query.length === 0) {
|
||||
return base.slice(0, 5);
|
||||
}
|
||||
return base.filter((entry) => entry.toLowerCase().includes(query)).slice(0, 5);
|
||||
}, [sharedCopyright, suggestions.copyrights]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSuggestions({ authors: [], copyrights: [] });
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await window.api.listMetadataSuggestions();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setSuggestions({
|
||||
authors: result.authors,
|
||||
copyrights: result.copyrights
|
||||
});
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to load metadata suggestions:', error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [metadataSuggestionsVersion]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (node) {
|
||||
node.scrollTop = getMultiEditorScrollTop();
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
setMultiEditorScrollTop(event.currentTarget.scrollTop);
|
||||
};
|
||||
|
||||
const handleSharedCustomNameBlur = async () => {
|
||||
const normalized = sharedCustomName.trim().length > 0 ? sharedCustomName.trim() : null;
|
||||
|
||||
if (!normalized) return;
|
||||
|
||||
try {
|
||||
let failureCount = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.categories.length > 0) {
|
||||
await onOrganize(file.id, { customName: normalized });
|
||||
} else {
|
||||
await onUpdateCustomName(file.id, normalized);
|
||||
}
|
||||
} catch (error) {
|
||||
failureCount += 1;
|
||||
console.error(`Failed to update custom name for file ${file.id} (${file.fileName}):`, error);
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
console.error(`${failureCount} file(s) failed to update`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update custom names:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSharedAuthorBlur = async () => {
|
||||
const normalized = sharedAuthor.trim();
|
||||
const authorPayload = normalized.length > 0 ? normalized : null;
|
||||
|
||||
try {
|
||||
let failureCount = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.categories.length > 0) {
|
||||
await onOrganize(file.id, { author: authorPayload });
|
||||
} else {
|
||||
await onUpdateMetadata(file.id, { author: authorPayload });
|
||||
}
|
||||
} catch (error) {
|
||||
failureCount += 1;
|
||||
console.error(`Failed to update author for file ${file.id} (${file.fileName}):`, error);
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
console.error(`${failureCount} file(s) failed to update author`);
|
||||
}
|
||||
|
||||
if (authorPayload && authorPayload.length > 0) {
|
||||
appendSuggestion(authorPayload, 'authors');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update author:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSharedCopyrightBlur = async () => {
|
||||
const normalized = sharedCopyright.trim();
|
||||
|
||||
if (normalized.length === 0) return;
|
||||
|
||||
try {
|
||||
let failureCount = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.categories.length > 0) {
|
||||
await onOrganize(file.id, { copyright: normalized });
|
||||
} else {
|
||||
await onUpdateMetadata(file.id, { copyright: normalized });
|
||||
}
|
||||
} catch (error) {
|
||||
failureCount += 1;
|
||||
console.error(`Failed to update copyright for file ${file.id} (${file.fileName}):`, error);
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
console.error(`${failureCount} file(s) failed to update copyright`);
|
||||
}
|
||||
|
||||
if (normalized.length > 0) {
|
||||
appendSuggestion(normalized, 'copyrights');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update copyright:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSharedRatingChange = async (rating: number) => {
|
||||
setSharedRating(rating);
|
||||
|
||||
try {
|
||||
let failureCount = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.categories.length > 0) {
|
||||
await onOrganize(file.id, { rating });
|
||||
} else {
|
||||
await onUpdateMetadata(file.id, { rating });
|
||||
}
|
||||
} catch (error) {
|
||||
failureCount += 1;
|
||||
console.error(`Failed to update rating for file ${file.id} (${file.fileName}):`, error);
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
console.error(`${failureCount} file(s) failed to update rating`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update rating:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagSave = async (data: { tags: string[]; categories: string[] }) => {
|
||||
try {
|
||||
let failureCount = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
await onUpdateTags(file.id, data);
|
||||
} catch (error) {
|
||||
failureCount += 1;
|
||||
console.error(`Failed to update tags for file ${file.id} (${file.fileName}):`, error);
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
console.error(`${failureCount} file(s) failed to update tags`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update tags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTagSection = () => {
|
||||
setIsTagSectionExpanded((value) => !value);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="multi-file-editor">
|
||||
<header className="multi-file-editor-header">
|
||||
<h2>Editing {files.length} files</h2>
|
||||
</header>
|
||||
|
||||
<div className="multi-file-editor-body" ref={listRef} onScroll={handleScroll}>
|
||||
<div className="multi-file-metadata-section">
|
||||
<h3>Shared Metadata</h3>
|
||||
<div className="multi-file-metadata-grid">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={sharedCustomName}
|
||||
onChange={(e) => setSharedCustomName(e.target.value)}
|
||||
onBlur={handleSharedCustomNameBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Custom name for all selected files"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Author</span>
|
||||
<input
|
||||
type="text"
|
||||
value={sharedAuthor}
|
||||
onChange={(e) => setSharedAuthor(e.target.value)}
|
||||
onBlur={handleSharedAuthorBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Artist or creator name"
|
||||
/>
|
||||
{filteredAuthorSuggestions.length > 0 && (
|
||||
<div className="metadata-suggestion-list" role="listbox" aria-label="Author suggestions">
|
||||
{filteredAuthorSuggestions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className="metadata-suggestion"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSharedAuthor(option);
|
||||
void handleSharedAuthorBlur();
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span>Copyright</span>
|
||||
<input
|
||||
type="text"
|
||||
value={sharedCopyright}
|
||||
onChange={(e) => setSharedCopyright(e.target.value)}
|
||||
onBlur={handleSharedCopyrightBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Copyright information"
|
||||
/>
|
||||
{filteredCopyrightSuggestions.length > 0 && (
|
||||
<div className="metadata-suggestion-list" role="listbox" aria-label="Copyright suggestions">
|
||||
{filteredCopyrightSuggestions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className="metadata-suggestion"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSharedCopyright(option);
|
||||
void handleSharedCopyrightBlur();
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span>Rating</span>
|
||||
<div className="star-rating">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
className={sharedRating >= star ? 'star star--filled' : 'star'}
|
||||
onClick={() => {
|
||||
const nextValue = sharedRating === star ? 0 : star;
|
||||
void handleSharedRatingChange(nextValue);
|
||||
}}
|
||||
aria-label={`${star} star${star > 1 ? 's' : ''}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="tag-editor-pane">
|
||||
<button
|
||||
type="button"
|
||||
className="tag-editor-toggle"
|
||||
onClick={toggleTagSection}
|
||||
aria-expanded={isTagSectionExpanded}
|
||||
>
|
||||
<span className="tag-editor-toggle-label">Tags</span>
|
||||
<span className="tag-editor-toggle-icon" aria-hidden="true">
|
||||
{isTagSectionExpanded ? 'v' : '>'}
|
||||
</span>
|
||||
</button>
|
||||
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
|
||||
<TagEditor
|
||||
tags={aggregatedTags}
|
||||
categories={aggregatedCategories}
|
||||
availableCategories={categories}
|
||||
onSave={handleTagSave}
|
||||
showHeading={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiFileEditor;
|
||||
215
src/renderer/src/components/SearchBar.tsx
Normal file
215
src/renderer/src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type SyntheticEvent
|
||||
} from 'react';
|
||||
|
||||
type SuggestionType = 'author' | 'copyright';
|
||||
|
||||
interface MetadataSuggestionPayload {
|
||||
authors: string[];
|
||||
copyrights: string[];
|
||||
}
|
||||
|
||||
export interface SearchBarProps {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
onRescan(): Promise<void>;
|
||||
onFindDuplicates(): void;
|
||||
onOpenSettings(): void;
|
||||
metadataSuggestionsVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top level search bar with quick access to rescanning and settings.
|
||||
*/
|
||||
export function SearchBar({ value, onChange, onRescan, onFindDuplicates, onOpenSettings, metadataSuggestionsVersion }: SearchBarProps): JSX.Element {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [caretIndex, setCaretIndex] = useState<number | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<MetadataSuggestionPayload>({ authors: [], copyrights: [] });
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
const [rescanBusy, setRescanBusy] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const refreshSuggestions = useCallback(async () => {
|
||||
setLoadingSuggestions(true);
|
||||
try {
|
||||
const result = await window.api.listMetadataSuggestions();
|
||||
setSuggestions({
|
||||
authors: result.authors,
|
||||
copyrights: result.copyrights
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load search suggestions', error);
|
||||
} finally {
|
||||
setLoadingSuggestions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestions({ authors: [], copyrights: [] });
|
||||
void refreshSuggestions();
|
||||
}, [refreshSuggestions, metadataSuggestionsVersion]);
|
||||
|
||||
const analysisTarget = useMemo(() => {
|
||||
const cursor = caretIndex ?? draft.length;
|
||||
return draft.slice(0, cursor);
|
||||
}, [caretIndex, draft]);
|
||||
|
||||
const activeToken = useMemo(() => {
|
||||
const match = analysisTarget.match(/(author|copyright):([^\s]*)$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const type = match[1].toLowerCase() as SuggestionType;
|
||||
return {
|
||||
type,
|
||||
term: match[2] ?? ''
|
||||
};
|
||||
}, [analysisTarget]);
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!activeToken) {
|
||||
return [] as string[];
|
||||
}
|
||||
const pool = activeToken.type === 'author' ? suggestions.authors : suggestions.copyrights;
|
||||
const trimmedTerm = activeToken.term.trim();
|
||||
if (trimmedTerm.length === 0) {
|
||||
return pool.slice(0, 6);
|
||||
}
|
||||
const needle = trimmedTerm.toLowerCase();
|
||||
return pool
|
||||
.filter((entry) => entry.toLowerCase().includes(needle))
|
||||
.slice(0, 6);
|
||||
}, [activeToken, suggestions]);
|
||||
|
||||
const activeSuggestions = useMemo(() => {
|
||||
if (!activeToken) {
|
||||
return [] as Array<{ type: SuggestionType; value: string }>;
|
||||
}
|
||||
return filteredSuggestions.map((value) => ({ type: activeToken.type, value }));
|
||||
}, [activeToken, filteredSuggestions]);
|
||||
|
||||
const showSuggestions = activeToken !== null && activeSuggestions.length > 0;
|
||||
const showLoading = loadingSuggestions && activeToken !== null;
|
||||
|
||||
const handleCaretUpdate = (event: SyntheticEvent<HTMLInputElement>) => {
|
||||
const target = event.currentTarget;
|
||||
setCaretIndex(target.selectionStart ?? target.value.length);
|
||||
};
|
||||
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextValue = event.target.value;
|
||||
setDraft(nextValue);
|
||||
setCaretIndex(event.target.selectionStart ?? nextValue.length);
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
const applySuggestion = (type: SuggestionType, suggestionValue: string) => {
|
||||
const previousValue = inputRef.current?.value ?? draft;
|
||||
const cursor = caretIndex ?? previousValue.length;
|
||||
const head = previousValue.slice(0, cursor);
|
||||
const tail = previousValue.slice(cursor);
|
||||
const pattern = new RegExp(`(${type}:)[^\s]*$`, 'i');
|
||||
let nextHead: string;
|
||||
if (pattern.test(head)) {
|
||||
nextHead = head.replace(pattern, `${type}:${suggestionValue}`);
|
||||
} else {
|
||||
const prefix = head.length === 0 || /\s$/.test(head) ? '' : ' ';
|
||||
nextHead = `${head}${prefix}${type}:${suggestionValue}`;
|
||||
}
|
||||
let nextValue = `${nextHead}${tail}`;
|
||||
let nextCursor = nextHead.length;
|
||||
if (tail.length > 0 && !/^\s/.test(tail)) {
|
||||
nextValue = `${nextHead} ${tail}`;
|
||||
nextCursor += 1;
|
||||
}
|
||||
setDraft(nextValue);
|
||||
setCaretIndex(nextCursor);
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
const node = inputRef.current;
|
||||
if (node) {
|
||||
node.focus();
|
||||
node.setSelectionRange(nextCursor, nextCursor);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (type: SuggestionType, suggestionValue: string) => {
|
||||
applySuggestion(type, suggestionValue);
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
if (!loadingSuggestions) {
|
||||
void refreshSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescanClick = async () => {
|
||||
setRescanBusy(true);
|
||||
try {
|
||||
await onRescan();
|
||||
await refreshSuggestions();
|
||||
} finally {
|
||||
setRescanBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="search-input"
|
||||
placeholder="Search by name, tag, category, or author:"
|
||||
value={draft}
|
||||
onChange={handleInputChange}
|
||||
onSelect={handleCaretUpdate}
|
||||
onKeyUp={handleCaretUpdate}
|
||||
onFocus={handleInputFocus}
|
||||
/>
|
||||
<div className="search-suggestions" aria-live="polite">
|
||||
{showLoading && <span className="search-suggestions__status">Loading suggestions…</span>}
|
||||
{showSuggestions && (
|
||||
<span className="search-suggestions__label">Suggestions:</span>
|
||||
)}
|
||||
{showSuggestions &&
|
||||
activeSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={`${suggestion.type}:${suggestion.value}`}
|
||||
type="button"
|
||||
className="search-suggestion"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => handleSuggestionClick(suggestion.type, suggestion.value)}
|
||||
>
|
||||
<span className="search-suggestion__type">{suggestion.type}:</span>
|
||||
<span className="search-suggestion__value">{suggestion.value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="search-actions">
|
||||
<button type="button" onClick={handleRescanClick} className="ghost-button" disabled={rescanBusy}>
|
||||
{rescanBusy ? 'Rescanning…' : 'Rescan'}
|
||||
</button>
|
||||
<button type="button" onClick={onFindDuplicates} className="ghost-button">
|
||||
Find Duplicates
|
||||
</button>
|
||||
<button type="button" onClick={onOpenSettings} className="ghost-button">
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchBar;
|
||||
67
src/renderer/src/components/SettingsDialog.tsx
Normal file
67
src/renderer/src/components/SettingsDialog.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState, type ChangeEvent } from 'react';
|
||||
|
||||
export interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
currentPath: string | null;
|
||||
onClose(): void;
|
||||
onSelectDirectory(path: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal dialog for selecting or typing a new library path.
|
||||
*/
|
||||
export function SettingsDialog({ open, currentPath, onClose, onSelectDirectory }: SettingsDialogProps): JSX.Element | null {
|
||||
const [draftPath, setDraftPath] = useState(currentPath ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
setDraftPath(currentPath ?? '');
|
||||
}, [currentPath]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pickDirectory = async () => {
|
||||
const directory = await window.api.selectLibraryDirectory();
|
||||
if (directory) {
|
||||
setDraftPath(directory);
|
||||
onSelectDirectory(directory);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (draftPath.trim().length > 0) {
|
||||
onSelectDirectory(draftPath.trim());
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Library Settings</h2>
|
||||
<label className="dialog-section">
|
||||
<span>Library Location</span>
|
||||
<input
|
||||
value={draftPath}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setDraftPath(event.target.value)}
|
||||
placeholder="Choose where your WAV library lives"
|
||||
/>
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="ghost-button" onClick={pickDirectory}>
|
||||
Browse…
|
||||
</button>
|
||||
<button type="button" className="primary-button" onClick={save}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsDialog;
|
||||
341
src/renderer/src/components/TagEditor.tsx
Normal file
341
src/renderer/src/components/TagEditor.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
type ChangeEvent,
|
||||
type UIEvent,
|
||||
type CSSProperties
|
||||
} from 'react';
|
||||
import type { CategoryRecord } from '../../../shared/models';
|
||||
import {
|
||||
getCollapsedGroups,
|
||||
initializeCollapsedGroups,
|
||||
setCollapsedGroups as setModuleCollapsedGroups,
|
||||
getTagEditorScrollTop,
|
||||
setTagEditorScrollTop
|
||||
} from '../stores/CategoryUIState';
|
||||
import {
|
||||
buildCategorySwatch,
|
||||
createCategoryStyleVars,
|
||||
formatCategoryLabel
|
||||
} from '../utils/categoryColors';
|
||||
import type { CategorySwatch } from '../utils/categoryColors';
|
||||
|
||||
export interface TagEditorProps {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
availableCategories: CategoryRecord[];
|
||||
onSave(data: { tags: string[]; categories: string[] }): void;
|
||||
/** If false the internal heading is omitted. Defaults to true. */
|
||||
showHeading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows editing free-form tags and selecting UCS categories.
|
||||
*/
|
||||
export function TagEditor({ tags, categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element {
|
||||
const [tagDraft, setTagDraft] = useState(tags.join(', '));
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [selectedCategories, setSelectedCategories] = useState(new Set(categories));
|
||||
const [isCategoryListExpanded, setIsCategoryListExpanded] = useState(false);
|
||||
|
||||
// Initialize module state if needed
|
||||
initializeCollapsedGroups(availableCategories.map((cat) => cat.category));
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useState(() => {
|
||||
const moduleState = getCollapsedGroups();
|
||||
return new Set(moduleState!);
|
||||
});
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
setTagDraft(tags.join(', '));
|
||||
setSelectedCategories(new Set(categories));
|
||||
|
||||
// Sync collapsed state from module state when file changes (but not on first render)
|
||||
if (!isFirstRender.current) {
|
||||
const moduleState = getCollapsedGroups();
|
||||
console.log('TagEditor syncing collapsed state from module:', Array.from(moduleState!));
|
||||
setCollapsedGroups(new Set(moduleState!));
|
||||
} else {
|
||||
isFirstRender.current = false;
|
||||
}
|
||||
}, [tags, categories]);
|
||||
|
||||
// No need for this effect anymore - categories don't change dynamically
|
||||
|
||||
const { groupedCategories, filteredResults } = useMemo(() => {
|
||||
const filter = categoryFilter.trim().toLowerCase();
|
||||
const filtered = filter.length === 0
|
||||
? availableCategories
|
||||
: availableCategories.filter((entry: CategoryRecord) => {
|
||||
const haystack = `${entry.category} ${entry.subCategory} ${entry.shortCode} ${entry.synonyms.join(' ')}`.toLowerCase();
|
||||
return haystack.includes(filter);
|
||||
});
|
||||
|
||||
const grouped = filtered.reduce((acc, category) => {
|
||||
if (!acc[category.category]) {
|
||||
acc[category.category] = [];
|
||||
}
|
||||
acc[category.category].push(category);
|
||||
return acc;
|
||||
}, {} as Record<string, CategoryRecord[]>);
|
||||
return { groupedCategories: grouped, filteredResults: filtered };
|
||||
}, [availableCategories, categoryFilter]);
|
||||
|
||||
const selectedCategoryRecords = useMemo(
|
||||
() => availableCategories
|
||||
.filter((entry) => selectedCategories.has(entry.id))
|
||||
.sort((left, right) => {
|
||||
const topLevelComparison = left.category.localeCompare(right.category);
|
||||
if (topLevelComparison !== 0) {
|
||||
return topLevelComparison;
|
||||
}
|
||||
return left.subCategory.localeCompare(right.subCategory);
|
||||
}),
|
||||
[availableCategories, selectedCategories]
|
||||
);
|
||||
|
||||
const categoryColorMap = useMemo(() => {
|
||||
const map = new Map<string, CategorySwatch>();
|
||||
availableCategories.forEach((entry) => {
|
||||
const existing = map.get(entry.category);
|
||||
const swatch = existing ?? buildCategorySwatch(entry.category);
|
||||
map.set(entry.category, swatch);
|
||||
map.set(entry.id, swatch);
|
||||
});
|
||||
return map;
|
||||
}, [availableCategories]);
|
||||
|
||||
const filterSuggestions = useMemo(() => {
|
||||
if (categoryFilter.trim().length === 0) {
|
||||
return [] as CategoryRecord[];
|
||||
}
|
||||
return filteredResults.slice(0, 6);
|
||||
}, [categoryFilter, filteredResults]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (node) {
|
||||
node.scrollTop = getTagEditorScrollTop();
|
||||
}
|
||||
}, [groupedCategories, collapsedGroups]);
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
setTagEditorScrollTop(event.currentTarget.scrollTop);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
const next = new Set(collapsedGroups);
|
||||
const moduleState = getCollapsedGroups()!;
|
||||
if (next.has(groupName)) {
|
||||
next.delete(groupName);
|
||||
moduleState.delete(groupName);
|
||||
} else {
|
||||
next.add(groupName);
|
||||
moduleState.add(groupName);
|
||||
}
|
||||
setModuleCollapsedGroups(moduleState);
|
||||
console.log('TagEditor toggleGroup:', groupName, 'collapsed:', next.has(groupName), 'module state:', Array.from(moduleState));
|
||||
setCollapsedGroups(next);
|
||||
};
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
const next = new Set(selectedCategories);
|
||||
if (next.has(categoryId)) {
|
||||
next.delete(categoryId);
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
}
|
||||
setSelectedCategories(next);
|
||||
|
||||
const parsedTags = tagDraft
|
||||
.split(',')
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => value.length > 0);
|
||||
onSave({ tags: parsedTags, categories: Array.from(next) });
|
||||
};
|
||||
|
||||
const handleTagBlur = () => {
|
||||
const parsedTags = tagDraft
|
||||
.split(',')
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => value.length > 0);
|
||||
onSave({ tags: parsedTags, categories: Array.from(selectedCategories) });
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (categoryId: string) => {
|
||||
if (!selectedCategories.has(categoryId)) {
|
||||
return;
|
||||
}
|
||||
toggleCategory(categoryId);
|
||||
};
|
||||
|
||||
const handleClearCategories = () => {
|
||||
if (selectedCategories.size === 0) {
|
||||
return;
|
||||
}
|
||||
setSelectedCategories(new Set());
|
||||
const parsedTags = tagDraft
|
||||
.split(',')
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => value.length > 0);
|
||||
onSave({ tags: parsedTags, categories: [] });
|
||||
};
|
||||
|
||||
const toggleCategoryList = () => {
|
||||
setIsCategoryListExpanded((value) => !value);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="tag-editor">
|
||||
{showHeading && <h2>Tags</h2>}
|
||||
<textarea
|
||||
value={tagDraft}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setTagDraft(event.target.value)}
|
||||
onBlur={handleTagBlur}
|
||||
placeholder="Comma separated tags"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="category-filter">
|
||||
<input
|
||||
value={categoryFilter}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setCategoryFilter(event.target.value)}
|
||||
placeholder="Filter UCS categories"
|
||||
/>
|
||||
</div>
|
||||
{categoryFilter.trim().length > 0 && (
|
||||
filterSuggestions.length > 0 ? (
|
||||
<div className="category-filter-suggestions">
|
||||
<span className="category-filter-suggestions-label">Suggestions</span>
|
||||
<div className="category-filter-suggestion-list">
|
||||
{filterSuggestions.map((entry) => {
|
||||
const selected = selectedCategories.has(entry.id);
|
||||
const colorStyle = createCategoryStyleVars(categoryColorMap.get(entry.id));
|
||||
const categoryLabel = formatCategoryLabel(entry.category);
|
||||
const subCategoryLabel = formatCategoryLabel(entry.subCategory);
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={selected ? 'category-filter-suggestion category-filter-suggestion--selected' : 'category-filter-suggestion'}
|
||||
onClick={() => toggleCategory(entry.id)}
|
||||
style={colorStyle}
|
||||
title={`${categoryLabel} ${subCategoryLabel}`}
|
||||
>
|
||||
<span className="category-filter-suggestion-name">
|
||||
<span className="category-label category-label--muted">{categoryLabel}</span>
|
||||
<span className="category-label category-label--primary">{subCategoryLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="category-filter-empty">No categories match this filter.</div>
|
||||
)
|
||||
)}
|
||||
<div className="selected-categories">
|
||||
<div className="selected-category-chip-list">
|
||||
{selectedCategoryRecords.length === 0 && <span className="selected-category-empty">No categories selected</span>}
|
||||
{selectedCategoryRecords.map((entry) => {
|
||||
const categoryLabel = formatCategoryLabel(entry.category);
|
||||
const subCategoryLabel = formatCategoryLabel(entry.subCategory);
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="selected-category-chip"
|
||||
onClick={() => handleRemoveCategory(entry.id)}
|
||||
style={createCategoryStyleVars(categoryColorMap.get(entry.id))}
|
||||
title={`${categoryLabel} ${subCategoryLabel}`}
|
||||
>
|
||||
<span className="selected-category-chip-label">
|
||||
<span className="category-label category-label--muted">{categoryLabel}</span>
|
||||
<span className="category-label category-label--primary">{subCategoryLabel}</span>
|
||||
</span>
|
||||
<span className="selected-category-chip-remove" aria-hidden="true">×</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button selected-category-clear"
|
||||
onClick={handleClearCategories}
|
||||
disabled={selectedCategories.size === 0}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div className="category-list-pane">
|
||||
<button
|
||||
type="button"
|
||||
className="category-list-toggle"
|
||||
onClick={toggleCategoryList}
|
||||
aria-expanded={isCategoryListExpanded}
|
||||
>
|
||||
<span className="category-list-toggle-label">All Categories</span>
|
||||
<span className="category-list-toggle-icon" aria-hidden="true">
|
||||
{isCategoryListExpanded ? 'v' : '>'}
|
||||
</span>
|
||||
</button>
|
||||
{isCategoryListExpanded && (
|
||||
<div className="category-list" ref={listRef} onScroll={handleScroll}>
|
||||
{Object.entries(groupedCategories).map(([groupName, groupCategories]) => {
|
||||
const isCollapsed = collapsedGroups.has(groupName);
|
||||
const displayGroupName = formatCategoryLabel(groupName);
|
||||
return (
|
||||
<div key={groupName} className="category-group">
|
||||
<button
|
||||
type="button"
|
||||
className="category-group-header"
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
>
|
||||
<span className="category-group-arrow">{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span className="category-group-title">{displayGroupName}</span>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="category-group-items">
|
||||
{groupCategories.map((entry: CategoryRecord) => {
|
||||
const selected = selectedCategories.has(entry.id);
|
||||
const colorStyle = createCategoryStyleVars(categoryColorMap.get(entry.id));
|
||||
const className = selected ? 'category-item category-item--selected' : 'category-item';
|
||||
const categoryLabel = formatCategoryLabel(entry.category);
|
||||
const subCategoryLabel = formatCategoryLabel(entry.subCategory);
|
||||
return (
|
||||
<label
|
||||
key={entry.id}
|
||||
className={className}
|
||||
style={colorStyle}
|
||||
title={`${categoryLabel} ${subCategoryLabel}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleCategory(entry.id)}
|
||||
/>
|
||||
<span className="category-item-label">
|
||||
<span className="category-label category-label--muted">{categoryLabel}</span>
|
||||
<span className="category-label category-label--primary">{subCategoryLabel}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagEditor;
|
||||
106
src/renderer/src/components/Waveform.tsx
Normal file
106
src/renderer/src/components/Waveform.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export interface WaveformProps {
|
||||
audioUrl: string | null;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an audio waveform visualization that serves as the background for the progress bar.
|
||||
*/
|
||||
export function Waveform({ audioUrl, currentTime, duration, className = '' }: WaveformProps): JSX.Element {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const waveformDataRef = useRef<number[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadWaveform = async () => {
|
||||
try {
|
||||
const audioContext = new AudioContext();
|
||||
const response = await fetch(audioUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
const rawData = audioBuffer.getChannelData(0);
|
||||
const samples = 500;
|
||||
const blockSize = Math.floor(rawData.length / samples);
|
||||
const filteredData: number[] = [];
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const blockStart = blockSize * i;
|
||||
let sum = 0;
|
||||
for (let j = 0; j < blockSize; j++) {
|
||||
sum += Math.abs(rawData[blockStart + j]);
|
||||
}
|
||||
filteredData.push(sum / blockSize);
|
||||
}
|
||||
|
||||
const multiplier = Math.max(...filteredData) ** -1;
|
||||
waveformDataRef.current = filteredData.map((n) => n * multiplier);
|
||||
drawWaveform();
|
||||
|
||||
await audioContext.close();
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate waveform', error);
|
||||
}
|
||||
};
|
||||
|
||||
void loadWaveform();
|
||||
}, [audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
drawWaveform();
|
||||
}, [currentTime, duration]);
|
||||
|
||||
const drawWaveform = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const data = waveformDataRef.current;
|
||||
if (!canvas || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const thumbWidth = 14;
|
||||
const padding = thumbWidth / 2;
|
||||
const drawableWidth = width - thumbWidth;
|
||||
const barWidth = drawableWidth / data.length;
|
||||
const playedProgress = duration > 0 ? currentTime / duration : 0;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const barHeight = data[i] * height * 0.8;
|
||||
const x = padding + (i * barWidth);
|
||||
const y = (height - barHeight) / 2;
|
||||
|
||||
if (i / data.length < playedProgress) {
|
||||
ctx.fillStyle = '#4a9eff';
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
}
|
||||
|
||||
ctx.fillRect(x, y, barWidth * 0.8, barHeight);
|
||||
}
|
||||
};
|
||||
|
||||
return <canvas ref={canvasRef} className={className} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
|
||||
export default Waveform;
|
||||
18
src/renderer/src/hooks/useLibrarySnapshot.ts
Normal file
18
src/renderer/src/hooks/useLibrarySnapshot.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { libraryStore, type LibrarySnapshot } from '../stores/LibraryStore';
|
||||
|
||||
/**
|
||||
* React hook to consume the library store snapshot with automatic bootstrap.
|
||||
*/
|
||||
export function useLibrarySnapshot(): LibrarySnapshot {
|
||||
useEffect(() => {
|
||||
if (!libraryStore.getSnapshot().initialized) {
|
||||
void libraryStore.bootstrap();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return useSyncExternalStore(
|
||||
(listener: () => void) => libraryStore.subscribe(listener),
|
||||
() => libraryStore.getSnapshot()
|
||||
);
|
||||
}
|
||||
25
src/renderer/src/hooks/usePlayerSnapshot.ts
Normal file
25
src/renderer/src/hooks/usePlayerSnapshot.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { playerStore, type PlayerSnapshot } from '../stores/PlayerStore';
|
||||
import type { AudioFileSummary } from '../../../shared/models';
|
||||
|
||||
/**
|
||||
* React hook to wire components to the player store.
|
||||
*/
|
||||
export function usePlayerSnapshot(): PlayerSnapshot {
|
||||
useEffect(() => () => playerStore.dispose(), []);
|
||||
return useSyncExternalStore(
|
||||
(listener: () => void) => playerStore.subscribe(listener),
|
||||
() => playerStore.getSnapshot()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper that loads a file into the audio player store.
|
||||
*/
|
||||
export function loadPlayerFile(file: AudioFileSummary | null, autoPlay = true): void {
|
||||
if (!file) {
|
||||
playerStore.dispose();
|
||||
return;
|
||||
}
|
||||
void playerStore.loadFile(file, autoPlay);
|
||||
}
|
||||
16
src/renderer/src/main.tsx
Normal file
16
src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/app.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('Root container #root was not found');
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
0
src/renderer/src/state/categoryUiState.ts
Normal file
0
src/renderer/src/state/categoryUiState.ts
Normal file
39
src/renderer/src/stores/CategoryUIState.ts
Normal file
39
src/renderer/src/stores/CategoryUIState.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared UI state for category panels across TagEditor and MultiFileEditor.
|
||||
* This allows collapsed/expanded state to persist when switching between files.
|
||||
*/
|
||||
|
||||
let collapsedGroupsState: Set<string> | null = null;
|
||||
let tagEditorScrollTop = 0;
|
||||
let multiEditorScrollTop = 0;
|
||||
|
||||
export function getCollapsedGroups(): Set<string> | null {
|
||||
return collapsedGroupsState;
|
||||
}
|
||||
|
||||
export function initializeCollapsedGroups(categories: string[]): void {
|
||||
if (collapsedGroupsState === null) {
|
||||
collapsedGroupsState = new Set(categories);
|
||||
}
|
||||
}
|
||||
|
||||
export function setCollapsedGroups(groups: Set<string>): void {
|
||||
console.log('CategoryUIState: setCollapsedGroups called with', Array.from(groups));
|
||||
collapsedGroupsState = groups;
|
||||
}
|
||||
|
||||
export function getTagEditorScrollTop(): number {
|
||||
return tagEditorScrollTop;
|
||||
}
|
||||
|
||||
export function setTagEditorScrollTop(scrollTop: number): void {
|
||||
tagEditorScrollTop = scrollTop;
|
||||
}
|
||||
|
||||
export function getMultiEditorScrollTop(): number {
|
||||
return multiEditorScrollTop;
|
||||
}
|
||||
|
||||
export function setMultiEditorScrollTop(scrollTop: number): void {
|
||||
multiEditorScrollTop = scrollTop;
|
||||
}
|
||||
507
src/renderer/src/stores/LibraryStore.ts
Normal file
507
src/renderer/src/stores/LibraryStore.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AudioFileSummary,
|
||||
CategoryRecord,
|
||||
LibraryScanSummary,
|
||||
TagUpdatePayload
|
||||
} from '../../../shared/models';
|
||||
|
||||
type ChangeListener = () => void;
|
||||
|
||||
export const CATEGORY_FILTER_UNTAGGED = 'untagged' as const;
|
||||
export type CategoryFilterValue = string | typeof CATEGORY_FILTER_UNTAGGED | null;
|
||||
|
||||
export interface LibrarySnapshot {
|
||||
initialized: boolean;
|
||||
files: AudioFileSummary[];
|
||||
visibleFiles: AudioFileSummary[];
|
||||
selectedFileId: number | null;
|
||||
selectedFileIds: Set<number>;
|
||||
focusedFile: AudioFileSummary | null;
|
||||
categories: CategoryRecord[];
|
||||
settings: AppSettings;
|
||||
searchQuery: string;
|
||||
categoryFilter: CategoryFilterValue;
|
||||
lastScan: LibraryScanSummary | null;
|
||||
metadataSuggestionsVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralises renderer-side library state management with a subscription mechanism for React hooks.
|
||||
*/
|
||||
export class LibraryStore extends EventTarget {
|
||||
private snapshot: LibrarySnapshot = {
|
||||
initialized: false,
|
||||
files: [],
|
||||
visibleFiles: [],
|
||||
selectedFileId: null,
|
||||
selectedFileIds: new Set(),
|
||||
focusedFile: null,
|
||||
categories: [],
|
||||
settings: { libraryPath: null },
|
||||
searchQuery: '',
|
||||
categoryFilter: null,
|
||||
lastScan: null,
|
||||
metadataSuggestionsVersion: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Bootstraps the store by loading settings, categories, and the current file list.
|
||||
*/
|
||||
public async bootstrap(): Promise<void> {
|
||||
const [settings, categories, files] = await Promise.all([
|
||||
window.api.getSettings(),
|
||||
window.api.listCategories(),
|
||||
window.api.listAudioFiles()
|
||||
]);
|
||||
const firstFileId = files.at(0)?.id ?? null;
|
||||
const firstFile = files.at(0) ?? null;
|
||||
const nextVersion = this.snapshot.metadataSuggestionsVersion + 1;
|
||||
this.snapshot = {
|
||||
initialized: true,
|
||||
files,
|
||||
visibleFiles: [],
|
||||
categories,
|
||||
settings,
|
||||
selectedFileId: firstFileId,
|
||||
selectedFileIds: firstFileId !== null ? new Set([firstFileId]) : new Set(),
|
||||
focusedFile: firstFile,
|
||||
searchQuery: '',
|
||||
categoryFilter: null,
|
||||
lastScan: null,
|
||||
metadataSuggestionsVersion: nextVersion
|
||||
};
|
||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* React friendly subscription helper.
|
||||
*/
|
||||
public subscribe(listener: ChangeListener): () => void {
|
||||
const handler = () => listener();
|
||||
this.addEventListener('change', handler as EventListener);
|
||||
return () => this.removeEventListener('change', handler as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot getter used by hooks.
|
||||
*/
|
||||
public getSnapshot(): LibrarySnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected file id (single selection or multi-select toggle).
|
||||
*/
|
||||
public selectFile(fileId: number | null, options: { multi?: boolean; range?: boolean } = {}): void {
|
||||
const previousFocused = this.snapshot.focusedFile;
|
||||
if (fileId !== null && !this.snapshot.visibleFiles.some((file) => file.id === fileId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.multi) {
|
||||
const newSelection = new Set(this.snapshot.selectedFileIds);
|
||||
if (fileId !== null) {
|
||||
if (newSelection.has(fileId)) {
|
||||
newSelection.delete(fileId);
|
||||
} else {
|
||||
newSelection.add(fileId);
|
||||
}
|
||||
}
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
selectedFileId: fileId,
|
||||
selectedFileIds: newSelection,
|
||||
focusedFile:
|
||||
fileId !== null
|
||||
? this.snapshot.files.find((file) => file.id === fileId) ?? (previousFocused?.id === fileId ? previousFocused : null)
|
||||
: previousFocused
|
||||
};
|
||||
} else if (options.range && fileId !== null && this.snapshot.selectedFileId !== null) {
|
||||
const visibleIds = this.snapshot.visibleFiles.map((f) => f.id);
|
||||
const currentIndex = visibleIds.indexOf(this.snapshot.selectedFileId);
|
||||
const targetIndex = visibleIds.indexOf(fileId);
|
||||
if (currentIndex !== -1 && targetIndex !== -1) {
|
||||
const start = Math.min(currentIndex, targetIndex);
|
||||
const end = Math.max(currentIndex, targetIndex);
|
||||
const rangeIds = visibleIds.slice(start, end + 1);
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
selectedFileId: fileId,
|
||||
selectedFileIds: new Set(rangeIds),
|
||||
focusedFile:
|
||||
this.snapshot.files.find((file) => file.id === fileId) ?? (previousFocused?.id === fileId ? previousFocused : null)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
selectedFileId: fileId,
|
||||
selectedFileIds: fileId !== null ? new Set([fileId]) : new Set(),
|
||||
focusedFile:
|
||||
fileId !== null
|
||||
? this.snapshot.files.find((file) => file.id === fileId) ?? (previousFocused?.id === fileId ? previousFocused : null)
|
||||
: null
|
||||
};
|
||||
}
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects every file currently visible in the library view.
|
||||
*/
|
||||
public selectAllVisibleFiles(): void {
|
||||
if (this.snapshot.visibleFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleIds = this.snapshot.visibleFiles.map((file) => file.id);
|
||||
const primaryId = this.snapshot.selectedFileId !== null && visibleIds.includes(this.snapshot.selectedFileId)
|
||||
? this.snapshot.selectedFileId
|
||||
: visibleIds[0];
|
||||
|
||||
const previousFocused = this.snapshot.focusedFile;
|
||||
const nextFocused = this.snapshot.files.find((file) => file.id === primaryId) ?? (previousFocused?.id === primaryId ? previousFocused : null);
|
||||
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
selectedFileId: primaryId,
|
||||
selectedFileIds: new Set(visibleIds),
|
||||
focusedFile: nextFocused
|
||||
};
|
||||
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps the selection forwards or backwards within the visible file list.
|
||||
*/
|
||||
public selectAdjacent(offset: number): void {
|
||||
if (!Number.isFinite(offset) || offset === 0 || this.snapshot.visibleFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleIds = this.snapshot.visibleFiles.map((file) => file.id);
|
||||
const currentIndex = this.snapshot.selectedFileId !== null
|
||||
? visibleIds.indexOf(this.snapshot.selectedFileId)
|
||||
: -1;
|
||||
|
||||
const fallbackIndex = offset > 0 ? 0 : visibleIds.length - 1;
|
||||
const baseIndex = currentIndex === -1 ? fallbackIndex : currentIndex + offset;
|
||||
const nextIndex = Math.min(Math.max(baseIndex, 0), visibleIds.length - 1);
|
||||
const nextId = visibleIds[nextIndex];
|
||||
|
||||
if (nextId === this.snapshot.selectedFileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousFocused = this.snapshot.focusedFile;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
selectedFileId: nextId,
|
||||
selectedFileIds: new Set([nextId]),
|
||||
focusedFile:
|
||||
this.snapshot.files.find((file) => file.id === nextId) ?? (previousFocused?.id === nextId ? previousFocused : null)
|
||||
};
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a fuzzy search request.
|
||||
*/
|
||||
public async search(query: string): Promise<void> {
|
||||
const results = query.trim().length === 0 ? await window.api.listAudioFiles() : await window.api.search(query);
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
files: results,
|
||||
searchQuery: query,
|
||||
selectedFileId: results.at(0)?.id ?? null,
|
||||
focusedFile: this.snapshot.focusedFile
|
||||
};
|
||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||
}
|
||||
|
||||
public setCategoryFilter(filter: CategoryFilterValue): void {
|
||||
if (this.snapshot.categoryFilter === filter) {
|
||||
return;
|
||||
}
|
||||
const { files, selectedFileId, focusedFile } = this.snapshot;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
categoryFilter: filter
|
||||
};
|
||||
|
||||
let visible: AudioFileSummary[];
|
||||
if (filter === CATEGORY_FILTER_UNTAGGED) {
|
||||
visible = files.filter((file) => file.categories.length === 0);
|
||||
} else if (filter) {
|
||||
visible = files.filter((file) => file.categories.includes(filter));
|
||||
} else {
|
||||
visible = files.slice();
|
||||
}
|
||||
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
visibleFiles: visible,
|
||||
selectedFileId,
|
||||
focusedFile
|
||||
};
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a full library rescan and updates cached files accordingly.
|
||||
*/
|
||||
public async rescan(): Promise<LibraryScanSummary> {
|
||||
const summary = await window.api.rescanLibrary();
|
||||
const files = await window.api.listAudioFiles();
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
files,
|
||||
selectedFileId: files.at(0)?.id ?? null,
|
||||
lastScan: summary,
|
||||
focusedFile: this.snapshot.focusedFile,
|
||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||
};
|
||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies tag mutations to both the backend and the cached snapshot.
|
||||
*/
|
||||
public async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
|
||||
const updated = await window.api.updateTagging(payload);
|
||||
this.applyFilePatch(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a file and replaces it inside the store.
|
||||
*/
|
||||
public async renameFile(fileId: number, newName: string): Promise<AudioFileSummary> {
|
||||
const updated = await window.api.renameFile(fileId, newName);
|
||||
this.applyFilePatch(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a file to another relative directory within the library.
|
||||
*/
|
||||
public async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
|
||||
const updated = await window.api.moveFile(fileId, targetRelativeDirectory);
|
||||
this.applyFilePatch(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically organizes a file based on its categories.
|
||||
*/
|
||||
public async organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<AudioFileSummary> {
|
||||
const currentSelection = this.snapshot.selectedFileId;
|
||||
const currentFocused = this.snapshot.focusedFile;
|
||||
const updated = await window.api.organizeFile(fileId, metadata);
|
||||
const files = await window.api.listAudioFiles();
|
||||
|
||||
const { categoryFilter } = this.snapshot;
|
||||
let visible: AudioFileSummary[];
|
||||
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
|
||||
visible = files.filter((file) => file.categories.length === 0);
|
||||
} else if (categoryFilter) {
|
||||
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
||||
} else {
|
||||
visible = files.slice();
|
||||
}
|
||||
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
files,
|
||||
visibleFiles: visible,
|
||||
selectedFileId: currentSelection,
|
||||
focusedFile: currentFocused,
|
||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||
};
|
||||
this.emitChange();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the custom name for a file without moving it.
|
||||
*/
|
||||
public async updateCustomName(fileId: number, customName: string | null): Promise<AudioFileSummary> {
|
||||
const updated = await window.api.updateCustomName(fileId, customName);
|
||||
this.applyFilePatch(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates metadata (author, copyright, rating) for a file without organizing it.
|
||||
*/
|
||||
public async updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void> {
|
||||
await window.api.updateFileMetadata(fileId, metadata);
|
||||
|
||||
// Refresh the file list to get updated metadata
|
||||
const files = await window.api.listAudioFiles();
|
||||
const { categoryFilter } = this.snapshot;
|
||||
let visible: AudioFileSummary[];
|
||||
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
|
||||
visible = files.filter((file) => file.categories.length === 0);
|
||||
} else if (categoryFilter) {
|
||||
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
||||
} else {
|
||||
visible = files.slice();
|
||||
}
|
||||
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
files,
|
||||
visibleFiles: visible,
|
||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||
};
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a new library path and refreshes the cached settings + file list.
|
||||
*/
|
||||
public async setLibraryPath(targetPath: string): Promise<void> {
|
||||
const settings = await window.api.setLibraryPath(targetPath);
|
||||
const files = await window.api.listAudioFiles();
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
settings,
|
||||
files,
|
||||
selectedFileId: files.at(0)?.id ?? null,
|
||||
categoryFilter: null,
|
||||
focusedFile: this.snapshot.focusedFile,
|
||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||
};
|
||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently selected file object.
|
||||
*/
|
||||
public getSelectedFile(): AudioFileSummary | null {
|
||||
if (!this.snapshot.selectedFileId) {
|
||||
return null;
|
||||
}
|
||||
return this.snapshot.files.find((file) => file.id === this.snapshot.selectedFileId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for retrieving the current settings.
|
||||
*/
|
||||
public getSettings(): AppSettings {
|
||||
return this.snapshot.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a change event for subscribers.
|
||||
*/
|
||||
private emitChange(): void {
|
||||
this.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
private refreshVisibleFiles(preferredId?: number | null, emit = true): void {
|
||||
const { files, categoryFilter } = this.snapshot;
|
||||
let visible: AudioFileSummary[];
|
||||
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
|
||||
visible = files.filter((file) => file.categories.length === 0);
|
||||
} else if (categoryFilter) {
|
||||
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
||||
} else {
|
||||
visible = files.slice();
|
||||
}
|
||||
|
||||
const desiredId = preferredId !== undefined ? preferredId : this.snapshot.selectedFileId;
|
||||
const enforceVisible = preferredId !== undefined;
|
||||
const nextSelected = this.resolveSelectedId(visible, desiredId, enforceVisible);
|
||||
|
||||
const nextSelectionSet = new Set<number>();
|
||||
if (enforceVisible) {
|
||||
const visibleIds = new Set(visible.map((file) => file.id));
|
||||
for (const id of this.snapshot.selectedFileIds) {
|
||||
if (visibleIds.has(id)) {
|
||||
nextSelectionSet.add(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const id of this.snapshot.selectedFileIds) {
|
||||
nextSelectionSet.add(id);
|
||||
}
|
||||
}
|
||||
if (nextSelected !== null) {
|
||||
nextSelectionSet.add(nextSelected);
|
||||
}
|
||||
const previousFocused = this.snapshot.focusedFile;
|
||||
const nextFocused = nextSelected !== null
|
||||
? this.snapshot.files.find((file) => file.id === nextSelected) ?? (previousFocused?.id === nextSelected ? previousFocused : null)
|
||||
: null;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
visibleFiles: visible,
|
||||
selectedFileId: nextSelected,
|
||||
selectedFileIds: nextSelectionSet,
|
||||
focusedFile: nextFocused
|
||||
};
|
||||
|
||||
if (emit) {
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSelectedId(visible: AudioFileSummary[], desiredId: number | null, enforceVisible: boolean): number | null {
|
||||
if (desiredId !== null) {
|
||||
if (!enforceVisible) {
|
||||
return desiredId;
|
||||
}
|
||||
if (visible.some((file) => file.id === desiredId)) {
|
||||
return desiredId;
|
||||
}
|
||||
}
|
||||
if (enforceVisible) {
|
||||
return visible.at(0)?.id ?? null;
|
||||
}
|
||||
return desiredId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a single file entry within the cached collection.
|
||||
*/
|
||||
private applyFilePatch(updated: AudioFileSummary): void {
|
||||
const nextFiles = this.snapshot.files.slice();
|
||||
const index = nextFiles.findIndex((file) => file.id === updated.id);
|
||||
const previous = index >= 0 ? nextFiles[index] : null;
|
||||
if (index >= 0) {
|
||||
nextFiles[index] = updated;
|
||||
} else {
|
||||
nextFiles.push(updated);
|
||||
}
|
||||
const shouldBumpSuggestions = previous !== null && (previous.fileName !== updated.fileName || previous.relativePath !== updated.relativePath);
|
||||
|
||||
const { categoryFilter, selectedFileId, focusedFile } = this.snapshot;
|
||||
let visible: AudioFileSummary[];
|
||||
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
|
||||
visible = nextFiles.filter((file) => file.categories.length === 0);
|
||||
} else if (categoryFilter) {
|
||||
visible = nextFiles.filter((file) => file.categories.includes(categoryFilter));
|
||||
} else {
|
||||
visible = nextFiles.slice();
|
||||
}
|
||||
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
files: nextFiles,
|
||||
visibleFiles: visible,
|
||||
selectedFileId,
|
||||
focusedFile,
|
||||
metadataSuggestionsVersion: shouldBumpSuggestions
|
||||
? this.snapshot.metadataSuggestionsVersion + 1
|
||||
: this.snapshot.metadataSuggestionsVersion
|
||||
};
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
export const libraryStore = new LibraryStore();
|
||||
78
src/renderer/src/stores/PlayerStore.ts
Normal file
78
src/renderer/src/stores/PlayerStore.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { AudioFileSummary } from '../../../shared/models';
|
||||
|
||||
type PlayerListener = () => void;
|
||||
|
||||
export interface PlayerSnapshot {
|
||||
status: 'idle' | 'loading' | 'ready' | 'error';
|
||||
currentFileId: number | null;
|
||||
audioUrl: string | null;
|
||||
autoPlay: boolean;
|
||||
error: string | null;
|
||||
loadCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal audio playback store that fetches WAV data through the preload bridge and exposes a blob URL.
|
||||
*/
|
||||
export class PlayerStore extends EventTarget {
|
||||
private snapshot: PlayerSnapshot = {
|
||||
status: 'idle',
|
||||
currentFileId: null,
|
||||
audioUrl: null,
|
||||
autoPlay: false,
|
||||
error: null,
|
||||
loadCount: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the latest snapshot.
|
||||
*/
|
||||
public getSnapshot(): PlayerSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to updates.
|
||||
*/
|
||||
public subscribe(listener: PlayerListener): () => void {
|
||||
const handler = () => listener();
|
||||
this.addEventListener('change', handler as EventListener);
|
||||
return () => this.removeEventListener('change', handler as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a new file from disk, emitting progress updates.
|
||||
*/
|
||||
public async loadFile(file: AudioFileSummary, autoPlay = true): Promise<void> {
|
||||
this.update({ status: 'loading', currentFileId: file.id, autoPlay, error: null });
|
||||
try {
|
||||
const payload = await window.api.getAudioBuffer(file.id);
|
||||
const blob = new Blob([payload.buffer], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (this.snapshot.audioUrl) {
|
||||
URL.revokeObjectURL(this.snapshot.audioUrl);
|
||||
}
|
||||
this.update({ status: 'ready', audioUrl: url, autoPlay, loadCount: this.snapshot.loadCount + 1 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load audio file.';
|
||||
this.update({ status: 'error', error: message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources when the player is disposed.
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.snapshot.audioUrl) {
|
||||
URL.revokeObjectURL(this.snapshot.audioUrl);
|
||||
}
|
||||
this.update({ status: 'idle', currentFileId: null, audioUrl: null, autoPlay: false, error: null, loadCount: 0 });
|
||||
}
|
||||
|
||||
private update(patch: Partial<PlayerSnapshot>): void {
|
||||
this.snapshot = { ...this.snapshot, ...patch };
|
||||
this.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
export const playerStore = new PlayerStore();
|
||||
1566
src/renderer/src/styles/app.css
Normal file
1566
src/renderer/src/styles/app.css
Normal file
File diff suppressed because it is too large
Load Diff
66
src/renderer/src/utils/categoryColors.ts
Normal file
66
src/renderer/src/utils/categoryColors.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Shared helpers for deriving deterministic color accents for UCS categories.
|
||||
*/
|
||||
export interface CategorySwatch {
|
||||
base: string;
|
||||
soft: string;
|
||||
strong: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic numeric hash for a category label.
|
||||
*/
|
||||
export function hashCategoryLabel(label: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < label.length; index += 1) {
|
||||
hash = (hash * 31 + label.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a hue-based color swatch derived from the provided category label.
|
||||
*/
|
||||
export function buildCategorySwatch(label: string): CategorySwatch {
|
||||
const normalized = label.trim().toLowerCase();
|
||||
const hue = hashCategoryLabel(normalized) % 360;
|
||||
const saturation = 62;
|
||||
const lightness = 52;
|
||||
|
||||
const base = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
const soft = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.18)`;
|
||||
const strong = `hsla(${hue}, ${saturation}%, ${Math.min(lightness + 8, 70)}%, 0.35)`;
|
||||
|
||||
return {
|
||||
base,
|
||||
soft,
|
||||
strong,
|
||||
text: '#ffffff'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates CSS custom property assignments from a swatch.
|
||||
*/
|
||||
export function createCategoryStyleVars(swatch?: CategorySwatch): CSSProperties | undefined {
|
||||
if (!swatch) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
'--category-color-base': swatch.base,
|
||||
'--category-color-soft': swatch.soft,
|
||||
'--category-color-strong': swatch.strong,
|
||||
'--category-color-text': swatch.text
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats UCS labels from uppercase source data into readable casing.
|
||||
*/
|
||||
export function formatCategoryLabel(value: string): string {
|
||||
const lower = value.trim().toLowerCase();
|
||||
return lower.replace(/(^|[\s\-\/'])([a-z])/g, (match, boundary, letter) => `${boundary}${letter.toUpperCase()}`);
|
||||
}
|
||||
80
src/shared/ipc.ts
Normal file
80
src/shared/ipc.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/** Enumerates IPC channels shared between renderer and main. */
|
||||
export const IPC_CHANNELS = {
|
||||
settingsGet: 'settings:get',
|
||||
settingsSetLibrary: 'settings:set-library',
|
||||
dialogSelectLibrary: 'dialog:select-library',
|
||||
libraryScan: 'library:scan',
|
||||
libraryList: 'library:list',
|
||||
libraryDuplicates: 'library:duplicates',
|
||||
libraryRename: 'library:rename',
|
||||
libraryMove: 'library:move',
|
||||
libraryOrganize: 'library:organize',
|
||||
libraryCustomName: 'library:custom-name',
|
||||
libraryOpenFolder: 'library:open-folder',
|
||||
libraryDelete: 'library:delete',
|
||||
libraryBuffer: 'library:buffer',
|
||||
libraryMetadata: 'library:metadata',
|
||||
libraryMetadataSuggestions: 'library:metadata-suggestions',
|
||||
libraryUpdateMetadata: 'library:update-metadata',
|
||||
libraryWaveformPreview: 'library:waveform-preview',
|
||||
tagsUpdate: 'tags:update',
|
||||
categoriesList: 'categories:list',
|
||||
searchQuery: 'search:query'
|
||||
} as const;
|
||||
|
||||
export type IpcChannelKeys = keyof typeof IPC_CHANNELS;
|
||||
|
||||
/**
|
||||
* API surface exposed to the renderer through the preload script.
|
||||
*/
|
||||
export interface RendererApi {
|
||||
/** Retrieves the persisted settings object. */
|
||||
getSettings(): Promise<import('./models').AppSettings>;
|
||||
/** Opens a dialog to select a new library path, returning the chosen directory or null. */
|
||||
selectLibraryDirectory(): Promise<string | null>;
|
||||
/** Persists the given library path. */
|
||||
setLibraryPath(path: string): Promise<import('./models').AppSettings>;
|
||||
/** Triggers a manual rescan of the library. */
|
||||
rescanLibrary(): Promise<import('./models').LibraryScanSummary>;
|
||||
/** Fetches the current list of audio files. */
|
||||
listAudioFiles(): Promise<import('./models').AudioFileSummary[]>;
|
||||
/** Fetches groups of duplicate files based on checksum. */
|
||||
listDuplicates(): Promise<{ checksum: string; files: import('./models').AudioFileSummary[] }[]>;
|
||||
/** Requests a rename for the target file. */
|
||||
renameFile(fileId: number, newFileName: string): Promise<import('./models').AudioFileSummary>;
|
||||
/** Moves a file to another subdirectory under the library root. */
|
||||
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
|
||||
/** Automatically organizes a file based on its categories. */
|
||||
organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<import('./models').AudioFileSummary>;
|
||||
/** Updates the custom name for a file. */
|
||||
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
|
||||
/** Opens the file's containing folder in the system file explorer. */
|
||||
openFileFolder(fileId: number): Promise<void>;
|
||||
/** Deletes files from disk and database. */
|
||||
deleteFiles(fileIds: number[]): Promise<void>;
|
||||
/** Fetches the binary data required for playback. */
|
||||
getAudioBuffer(fileId: number): Promise<import('./models').AudioBufferPayload>;
|
||||
/** Returns a lightweight waveform preview for rendering list backgrounds. */
|
||||
getWaveformPreview(fileId: number, pointCount?: number): Promise<{ samples: number[]; rms: number }>;
|
||||
/** Updates free-form tags and UCS categories. */
|
||||
updateTagging(payload: import('./models').TagUpdatePayload): Promise<import('./models').AudioFileSummary>;
|
||||
/** Returns the catalog of UCS categories. */
|
||||
listCategories(): Promise<import('./models').CategoryRecord[]>;
|
||||
/** Runs fuzzy search returning ranked results. */
|
||||
search(query: string): Promise<import('./models').AudioFileSummary[]>;
|
||||
/** Reads embedded metadata from a WAV file. */
|
||||
readFileMetadata(fileId: number): Promise<{ author?: string; copyright?: string; title?: string; rating?: number }>;
|
||||
/** Lists distinct metadata values gathered from the library for quick suggestions. */
|
||||
listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }>;
|
||||
/** Updates metadata (author, copyright, rating) without organizing the file. */
|
||||
updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void>;
|
||||
/** Listens for menu actions from the main process. Returns a cleanup function. */
|
||||
onMenuAction(channel: string, callback: () => void): () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Bridge API injected by the preload script. */
|
||||
api: RendererApi;
|
||||
}
|
||||
}
|
||||
109
src/shared/models.ts
Normal file
109
src/shared/models.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Represents a summarized view of an audio file for list displays and searches.
|
||||
*/
|
||||
export interface AudioFileSummary {
|
||||
/** Unique identifier inside the database. */
|
||||
id: number;
|
||||
/** Filename with extension. */
|
||||
fileName: string;
|
||||
/** User-friendly display name (no extension). */
|
||||
displayName: string;
|
||||
/** Absolute path on disk. */
|
||||
absolutePath: string;
|
||||
/** Path relative to the configured library root. */
|
||||
relativePath: string;
|
||||
/** Last modified timestamp (epoch milliseconds). */
|
||||
modifiedAt: number;
|
||||
/** File creation timestamp (epoch milliseconds) 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;
|
||||
/** Content checksum used for duplicate detection. */
|
||||
checksum: string | null;
|
||||
/** Applied descriptive tags. */
|
||||
tags: string[];
|
||||
/** Associated UCS categories. */
|
||||
categories: string[];
|
||||
/** Custom name for file organization. */
|
||||
customName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a group of files that share the same checksum.
|
||||
*/
|
||||
export interface DuplicateGroup {
|
||||
/** MD5 checksum shared by all files in the group. */
|
||||
checksum: string;
|
||||
/** Files that have identical content. */
|
||||
files: AudioFileSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure describing a UCS category.
|
||||
*/
|
||||
export interface CategoryRecord {
|
||||
/** Category identifier (CatID from UCS). */
|
||||
id: string;
|
||||
/** High level category name. */
|
||||
category: string;
|
||||
/** Sub-category label. */
|
||||
subCategory: string;
|
||||
/** Short hand for the category. */
|
||||
shortCode: string;
|
||||
/** Human readable explanation. */
|
||||
explanation: string;
|
||||
/** List of synonym keywords. */
|
||||
synonyms: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary report for a library scan run.
|
||||
*/
|
||||
export interface LibraryScanSummary {
|
||||
/** Number of newly discovered files. */
|
||||
added: number;
|
||||
/** Number of existing records refreshed. */
|
||||
updated: number;
|
||||
/** Number of database records removed because the files disappeared. */
|
||||
removed: number;
|
||||
/** Total records after the scan. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of persisted application settings.
|
||||
*/
|
||||
export interface AppSettings {
|
||||
/** Current library root path. */
|
||||
libraryPath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for tag updates flowing from the renderer to the main process.
|
||||
*/
|
||||
export interface TagUpdatePayload {
|
||||
/** Target audio file id. */
|
||||
fileId: number;
|
||||
/** Free-form tag collection. */
|
||||
tags: string[];
|
||||
/** UCS category identifiers to attach. */
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for playback requests containing file binary data.
|
||||
*/
|
||||
export interface AudioBufferPayload {
|
||||
/** Original file id to trace usage. */
|
||||
fileId: number;
|
||||
/** Raw audio data. */
|
||||
buffer: ArrayBuffer;
|
||||
/** MIME type for the audio data. */
|
||||
mimeType: string;
|
||||
}
|
||||
433
src/test/DatabaseService.test.ts
Normal file
433
src/test/DatabaseService.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Comprehensive test suite for DatabaseService.
|
||||
* Run with: node --import tsx --test src/test/DatabaseService.test.ts
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { DatabaseService } from '../main/services/DatabaseService';
|
||||
import { getTempDbPath } from './testHelpers';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
let database: DatabaseService;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = getTempDbPath();
|
||||
database = new DatabaseService(dbPath);
|
||||
database.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
database.close();
|
||||
await fs.unlink(dbPath).catch(() => {});
|
||||
});
|
||||
|
||||
describe('file management', () => {
|
||||
it('should upsert file record', () => {
|
||||
const record = database.upsertFile({
|
||||
absolutePath: '/test/file.wav',
|
||||
relativePath: 'file.wav',
|
||||
fileName: 'file.wav',
|
||||
displayName: 'file',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: 'abc123',
|
||||
tags: ['test'],
|
||||
categories: []
|
||||
});
|
||||
|
||||
assert.ok(record.id);
|
||||
assert.strictEqual(record.fileName, 'file.wav');
|
||||
});
|
||||
|
||||
it('should list all files', () => {
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file1.wav',
|
||||
relativePath: 'file1.wav',
|
||||
fileName: 'file1.wav',
|
||||
displayName: 'file1',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file2.wav',
|
||||
relativePath: 'file2.wav',
|
||||
fileName: 'file2.wav',
|
||||
displayName: 'file2',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const files = database.listFiles();
|
||||
assert.strictEqual(files.length, 2);
|
||||
});
|
||||
|
||||
it('should get file by id', () => {
|
||||
const inserted = database.upsertFile({
|
||||
absolutePath: '/test/file.wav',
|
||||
relativePath: 'file.wav',
|
||||
fileName: 'file.wav',
|
||||
displayName: 'file',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const retrieved = database.getFileById(inserted.id);
|
||||
assert.strictEqual(retrieved.id, inserted.id);
|
||||
assert.strictEqual(retrieved.fileName, 'file.wav');
|
||||
});
|
||||
|
||||
it('should update file location', () => {
|
||||
const file = database.upsertFile({
|
||||
absolutePath: '/test/file.wav',
|
||||
relativePath: 'file.wav',
|
||||
fileName: 'file.wav',
|
||||
displayName: 'file',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const updated = database.updateFileLocation(
|
||||
file.id,
|
||||
'/test/newdir/file.wav',
|
||||
'newdir/file.wav',
|
||||
'file.wav',
|
||||
'file'
|
||||
);
|
||||
|
||||
assert.strictEqual(updated.absolutePath, '/test/newdir/file.wav');
|
||||
assert.strictEqual(updated.relativePath, 'newdir/file.wav');
|
||||
});
|
||||
|
||||
it('should delete file', () => {
|
||||
const file = database.upsertFile({
|
||||
absolutePath: '/test/file.wav',
|
||||
relativePath: 'file.wav',
|
||||
fileName: 'file.wav',
|
||||
displayName: 'file',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
database.deleteFile(file.id);
|
||||
|
||||
const files = database.listFiles();
|
||||
assert.strictEqual(files.length, 0);
|
||||
});
|
||||
|
||||
it('should remove files outside set', () => {
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file1.wav',
|
||||
relativePath: 'file1.wav',
|
||||
fileName: 'file1.wav',
|
||||
displayName: 'file1',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file2.wav',
|
||||
relativePath: 'file2.wav',
|
||||
fileName: 'file2.wav',
|
||||
displayName: 'file2',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const kept = new Set(['/test/file1.wav']);
|
||||
const removed = database.removeFilesOutside(kept);
|
||||
|
||||
assert.strictEqual(removed, 1);
|
||||
assert.strictEqual(database.listFiles().length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagging', () => {
|
||||
it('should update file tags', () => {
|
||||
const file = database.upsertFile({
|
||||
absolutePath: '/test/file.wav',
|
||||
relativePath: 'file.wav',
|
||||
fileName: 'file.wav',
|
||||
displayName: 'file',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const updated = database.updateTagging(file.id, ['tag1', 'tag2'], ['cat1']);
|
||||
|
||||
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
|
||||
assert.deepStrictEqual(updated.categories, ['cat1']);
|
||||
});
|
||||
|
||||
it('should update custom name', () => {
|
||||
const file = database.upsertFile({
|
||||
absolutePath: '/test/file.wav',
|
||||
relativePath: 'file.wav',
|
||||
fileName: 'file.wav',
|
||||
displayName: 'file',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const updated = database.updateCustomName(file.id, 'Custom Name');
|
||||
|
||||
assert.strictEqual(updated.customName, 'Custom Name');
|
||||
});
|
||||
|
||||
it('should clear custom name', () => {
|
||||
const file = database.upsertFile({
|
||||
absolutePath: '/test/file.wav',
|
||||
relativePath: 'file.wav',
|
||||
fileName: 'file.wav',
|
||||
displayName: 'file',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
database.updateCustomName(file.id, 'Custom Name');
|
||||
const updated = database.updateCustomName(file.id, null);
|
||||
|
||||
assert.strictEqual(updated.customName, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('categories', () => {
|
||||
it('should upsert category', () => {
|
||||
database.upsertCategory({
|
||||
id: 'TEST',
|
||||
category: 'TEST CATEGORY',
|
||||
subCategory: 'Test Sub',
|
||||
shortCode: 'TST',
|
||||
explanation: 'Test explanation',
|
||||
synonyms: ['test', 'sample']
|
||||
});
|
||||
|
||||
const categories = database.listCategories();
|
||||
assert.ok(categories.some(c => c.id === 'TEST'));
|
||||
});
|
||||
|
||||
it('should get category by id', () => {
|
||||
database.upsertCategory({
|
||||
id: 'TEST',
|
||||
category: 'TEST CATEGORY',
|
||||
subCategory: 'Test Sub',
|
||||
shortCode: 'TST',
|
||||
explanation: 'Test explanation',
|
||||
synonyms: ['test', 'sample']
|
||||
});
|
||||
|
||||
const category = database.getCategoryById('TEST');
|
||||
assert.ok(category);
|
||||
assert.strictEqual(category.id, 'TEST');
|
||||
assert.strictEqual(category.shortCode, 'TST');
|
||||
});
|
||||
|
||||
it('should list all categories', () => {
|
||||
database.upsertCategory({
|
||||
id: 'TEST1',
|
||||
category: 'TEST CATEGORY',
|
||||
subCategory: 'Test Sub 1',
|
||||
shortCode: 'TS1',
|
||||
explanation: 'Test',
|
||||
synonyms: []
|
||||
});
|
||||
|
||||
database.upsertCategory({
|
||||
id: 'TEST2',
|
||||
category: 'TEST CATEGORY',
|
||||
subCategory: 'Test Sub 2',
|
||||
shortCode: 'TS2',
|
||||
explanation: 'Test',
|
||||
synonyms: []
|
||||
});
|
||||
|
||||
const categories = database.listCategories();
|
||||
assert.ok(categories.length >= 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicates', () => {
|
||||
it('should detect duplicate files by checksum', () => {
|
||||
const checksum = 'duplicate-checksum';
|
||||
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file1.wav',
|
||||
relativePath: 'file1.wav',
|
||||
fileName: 'file1.wav',
|
||||
displayName: 'file1',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file2.wav',
|
||||
relativePath: 'file2.wav',
|
||||
fileName: 'file2.wav',
|
||||
displayName: 'file2',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum,
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const duplicates = database.listDuplicateGroups();
|
||||
assert.strictEqual(duplicates.length, 1);
|
||||
assert.strictEqual(duplicates[0].checksum, checksum);
|
||||
assert.strictEqual(duplicates[0].files.length, 2);
|
||||
});
|
||||
|
||||
it('should not include unique files in duplicates', () => {
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file1.wav',
|
||||
relativePath: 'file1.wav',
|
||||
fileName: 'file1.wav',
|
||||
displayName: 'file1',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: 'unique1',
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
database.upsertFile({
|
||||
absolutePath: '/test/file2.wav',
|
||||
relativePath: 'file2.wav',
|
||||
fileName: 'file2.wav',
|
||||
displayName: 'file2',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: 'unique2',
|
||||
tags: [],
|
||||
categories: []
|
||||
});
|
||||
|
||||
const duplicates = database.listDuplicateGroups();
|
||||
assert.strictEqual(duplicates.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
it('should save and retrieve library path setting', () => {
|
||||
database.setSetting('libraryPath', '/test/library');
|
||||
const settings = database.getSettings();
|
||||
|
||||
assert.strictEqual(settings.libraryPath, '/test/library');
|
||||
});
|
||||
|
||||
it('should return null for missing library path', () => {
|
||||
const settings = database.getSettings();
|
||||
assert.strictEqual(settings.libraryPath, null);
|
||||
});
|
||||
|
||||
it('should overwrite existing setting', () => {
|
||||
database.setSetting('libraryPath', '/path1');
|
||||
database.setSetting('libraryPath', '/path2');
|
||||
|
||||
const settings = database.getSettings();
|
||||
assert.strictEqual(settings.libraryPath, '/path2');
|
||||
});
|
||||
|
||||
it('should handle custom settings', () => {
|
||||
database.setSetting('customKey', { test: 'value' });
|
||||
// Settings are stored as JSON strings internally
|
||||
// Just verify no errors thrown
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
389
src/test/LibraryService.test.ts
Normal file
389
src/test/LibraryService.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Comprehensive test suite for LibraryService.
|
||||
* Run with: node --import tsx --test src/test/LibraryService.test.ts
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { LibraryService } from '../main/services/LibraryService';
|
||||
import { DatabaseService } from '../main/services/DatabaseService';
|
||||
import { SettingsService } from '../main/services/SettingsService';
|
||||
import { TagService } from '../main/services/TagService';
|
||||
import { SearchService } from '../main/services/SearchService';
|
||||
import { TestWavGenerator, getTempDbPath, getTempLibraryPath } from './testHelpers';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('LibraryService', () => {
|
||||
let libraryService: LibraryService;
|
||||
let database: DatabaseService;
|
||||
let settings: SettingsService;
|
||||
let tagService: TagService;
|
||||
let searchService: SearchService;
|
||||
let dbPath: string;
|
||||
let libraryPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = getTempDbPath();
|
||||
libraryPath = getTempLibraryPath();
|
||||
|
||||
database = new DatabaseService(dbPath);
|
||||
database.initialize();
|
||||
settings = new SettingsService(database);
|
||||
tagService = new TagService(database);
|
||||
searchService = new SearchService(database, tagService);
|
||||
libraryService = new LibraryService(database, settings, tagService, searchService);
|
||||
|
||||
// Set library path in database
|
||||
database.setSetting('libraryPath', libraryPath);
|
||||
|
||||
// Create test files
|
||||
await TestWavGenerator.createTestLibrary(libraryPath, [
|
||||
{
|
||||
relativePath: 'test1.wav',
|
||||
options: {
|
||||
duration: 1,
|
||||
author: 'Author 1',
|
||||
copyright: 'Copyright 1',
|
||||
title: 'Test 1',
|
||||
rating: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
relativePath: 'subfolder/test2.wav',
|
||||
options: {
|
||||
duration: 2,
|
||||
author: 'Author 2',
|
||||
copyright: 'Copyright 2',
|
||||
title: 'Test 2',
|
||||
rating: 5
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
database.close();
|
||||
await TestWavGenerator.cleanupTestLibrary(libraryPath);
|
||||
await fs.unlink(dbPath).catch(() => {});
|
||||
});
|
||||
|
||||
describe('scanLibrary', () => {
|
||||
it('should discover WAV files in library', async () => {
|
||||
const result = await libraryService.scanLibrary();
|
||||
|
||||
assert.strictEqual(result.added, 2);
|
||||
assert.strictEqual(result.total, 2);
|
||||
});
|
||||
|
||||
it('should read embedded metadata during scan', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file1 = files.find(f => f.fileName === 'test1.wav');
|
||||
|
||||
assert.ok(file1);
|
||||
assert.strictEqual(file1.customName, 'Test 1');
|
||||
});
|
||||
|
||||
it('should detect updated files on rescan', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
// Modify a file
|
||||
const testFile = path.join(libraryPath, 'test1.wav');
|
||||
await TestWavGenerator.writeTestWav(testFile, {
|
||||
duration: 1,
|
||||
author: 'Modified Author'
|
||||
});
|
||||
|
||||
const result = await libraryService.scanLibrary();
|
||||
assert.strictEqual(result.updated, 2); // Both files are "updated" on rescan
|
||||
});
|
||||
|
||||
it('should remove deleted files from database', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
// Delete a file
|
||||
await fs.unlink(path.join(libraryPath, 'test1.wav'));
|
||||
|
||||
const result = await libraryService.scanLibrary();
|
||||
assert.strictEqual(result.removed, 1);
|
||||
assert.strictEqual(result.total, 1);
|
||||
});
|
||||
|
||||
it('should clean up orphaned temp files', async () => {
|
||||
// Create a temp file
|
||||
const tempFile = path.join(libraryPath, 'test.1234567890-1-abc123.tmp');
|
||||
await fs.writeFile(tempFile, 'temp data');
|
||||
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
// Temp file should be cleaned up
|
||||
const tempExists = await fs.access(tempFile).then(() => true).catch(() => false);
|
||||
assert.strictEqual(tempExists, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFiles', () => {
|
||||
it('should return all files in library', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
assert.strictEqual(files.length, 2);
|
||||
});
|
||||
|
||||
it('should include file metadata', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
assert.ok(file.absolutePath);
|
||||
assert.ok(file.fileName);
|
||||
assert.ok(file.relativePath);
|
||||
assert.ok(typeof file.size === 'number');
|
||||
assert.ok(typeof file.durationMs === 'number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameFile', () => {
|
||||
it('should rename a file', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
const updated = await libraryService.renameFile(file.id, 'renamed.wav');
|
||||
|
||||
assert.strictEqual(updated.fileName, 'renamed.wav');
|
||||
assert.ok(updated.absolutePath.endsWith('renamed.wav'));
|
||||
});
|
||||
|
||||
it('should update file on disk', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
const originalPath = file.absolutePath;
|
||||
|
||||
const updated = await libraryService.renameFile(file.id, 'renamed.wav');
|
||||
|
||||
const originalExists = await fs.access(originalPath).then(() => true).catch(() => false);
|
||||
const newExists = await fs.access(updated.absolutePath).then(() => true).catch(() => false);
|
||||
|
||||
assert.strictEqual(originalExists, false);
|
||||
assert.strictEqual(newExists, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveFile', () => {
|
||||
it('should move file to different directory', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files.find(f => f.fileName === 'test1.wav');
|
||||
|
||||
assert.ok(file);
|
||||
const updated = await libraryService.moveFile(file.id, 'newdir');
|
||||
|
||||
assert.ok(updated.relativePath.includes('newdir'));
|
||||
});
|
||||
|
||||
it('should create target directory if needed', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
await libraryService.moveFile(file.id, 'new/nested/dir');
|
||||
|
||||
const dirExists = await fs.access(path.join(libraryPath, 'new/nested/dir'))
|
||||
.then(() => true).catch(() => false);
|
||||
|
||||
assert.strictEqual(dirExists, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCustomName', () => {
|
||||
it('should update custom name in database', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
const updated = libraryService.updateCustomName(file.id, 'Custom Name');
|
||||
|
||||
assert.strictEqual(updated.customName, 'Custom Name');
|
||||
});
|
||||
|
||||
it('should allow clearing custom name', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
libraryService.updateCustomName(file.id, 'Custom Name');
|
||||
const updated = libraryService.updateCustomName(file.id, null);
|
||||
|
||||
assert.strictEqual(updated.customName, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFileMetadata', () => {
|
||||
it('should update author metadata', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
await libraryService.updateFileMetadata(file.id, {
|
||||
author: 'New Author'
|
||||
});
|
||||
|
||||
const metadata = await libraryService.readFileMetadata(file.id);
|
||||
assert.strictEqual(metadata.author, 'New Author');
|
||||
});
|
||||
|
||||
it('should update copyright metadata', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
await libraryService.updateFileMetadata(file.id, {
|
||||
copyright: 'New Copyright'
|
||||
});
|
||||
|
||||
const metadata = await libraryService.readFileMetadata(file.id);
|
||||
assert.strictEqual(metadata.copyright, 'New Copyright');
|
||||
});
|
||||
|
||||
it('should update rating metadata', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
await libraryService.updateFileMetadata(file.id, {
|
||||
rating: 4
|
||||
});
|
||||
|
||||
const metadata = await libraryService.readFileMetadata(file.id);
|
||||
assert.strictEqual(metadata.rating, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFileMetadata', () => {
|
||||
it('should read embedded metadata', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
const metadata = await libraryService.readFileMetadata(file.id);
|
||||
|
||||
assert.ok(metadata);
|
||||
assert.ok(metadata.author);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listMetadataSuggestions', () => {
|
||||
it('should aggregate authors from library', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const suggestions = await libraryService.listMetadataSuggestions();
|
||||
|
||||
assert.ok(suggestions.authors.length > 0);
|
||||
assert.ok(suggestions.authors.includes('Author 1'));
|
||||
assert.ok(suggestions.authors.includes('Author 2'));
|
||||
});
|
||||
|
||||
it('should aggregate copyrights from library', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const suggestions = await libraryService.listMetadataSuggestions();
|
||||
|
||||
assert.ok(suggestions.copyrights.length > 0);
|
||||
assert.ok(suggestions.copyrights.includes('Copyright 1'));
|
||||
assert.ok(suggestions.copyrights.includes('Copyright 2'));
|
||||
});
|
||||
|
||||
it('should return sorted suggestions', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const suggestions = await libraryService.listMetadataSuggestions();
|
||||
|
||||
// Check if arrays are sorted
|
||||
const authorsSorted = [...suggestions.authors].sort();
|
||||
assert.deepStrictEqual(suggestions.authors, authorsSorted);
|
||||
|
||||
const copyrightsSorted = [...suggestions.copyrights].sort();
|
||||
assert.deepStrictEqual(suggestions.copyrights, copyrightsSorted);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
it('should delete file from disk', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
const filePath = file.absolutePath;
|
||||
|
||||
await libraryService.deleteFiles([file.id]);
|
||||
|
||||
const exists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||
assert.strictEqual(exists, false);
|
||||
});
|
||||
|
||||
it('should remove file from database', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const file = files[0];
|
||||
|
||||
await libraryService.deleteFiles([file.id]);
|
||||
|
||||
const remaining = libraryService.listFiles();
|
||||
assert.strictEqual(remaining.length, 1);
|
||||
assert.ok(!remaining.find(f => f.id === file.id));
|
||||
});
|
||||
|
||||
it('should handle multiple files', async () => {
|
||||
await libraryService.scanLibrary();
|
||||
|
||||
const files = libraryService.listFiles();
|
||||
const ids = files.map(f => f.id);
|
||||
|
||||
await libraryService.deleteFiles(ids);
|
||||
|
||||
const remaining = libraryService.listFiles();
|
||||
assert.strictEqual(remaining.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupTempFiles', () => {
|
||||
it('should remove orphaned temp files', async () => {
|
||||
const tempFile = path.join(libraryPath, 'test.1234567890-1-abc123.tmp');
|
||||
await fs.writeFile(tempFile, 'temp');
|
||||
|
||||
const cleaned = await libraryService.cleanupTempFiles();
|
||||
|
||||
assert.strictEqual(cleaned, 1);
|
||||
const exists = await fs.access(tempFile).then(() => true).catch(() => false);
|
||||
assert.strictEqual(exists, false);
|
||||
});
|
||||
|
||||
it('should recover temp files when final file missing', async () => {
|
||||
const finalPath = path.join(libraryPath, 'test.wav');
|
||||
const tempFile = `${finalPath}.1234567890-1-abc123.tmp`;
|
||||
|
||||
await fs.writeFile(tempFile, 'temp data');
|
||||
|
||||
const cleaned = await libraryService.cleanupTempFiles();
|
||||
|
||||
assert.strictEqual(cleaned, 1);
|
||||
const finalExists = await fs.access(finalPath).then(() => true).catch(() => false);
|
||||
assert.strictEqual(finalExists, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
232
src/test/SearchService.test.ts
Normal file
232
src/test/SearchService.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Comprehensive test suite for SearchService.
|
||||
* Run with: node --import tsx --test src/test/SearchService.test.ts
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { SearchService } from '../main/services/SearchService';
|
||||
import { DatabaseService } from '../main/services/DatabaseService';
|
||||
import { TagService } from '../main/services/TagService';
|
||||
import { TestWavGenerator, getTempDbPath, getTempLibraryPath } from './testHelpers';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('SearchService', () => {
|
||||
let searchService: SearchService;
|
||||
let database: DatabaseService;
|
||||
let tagService: TagService;
|
||||
let dbPath: string;
|
||||
let libraryPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = getTempDbPath();
|
||||
libraryPath = getTempLibraryPath();
|
||||
|
||||
database = new DatabaseService(dbPath);
|
||||
database.initialize();
|
||||
tagService = new TagService(database);
|
||||
searchService = new SearchService(database, tagService);
|
||||
|
||||
// Create test files with various metadata
|
||||
await TestWavGenerator.createTestLibrary(libraryPath, [
|
||||
{
|
||||
relativePath: 'ambience/city/traffic.wav',
|
||||
options: {
|
||||
author: 'John Doe',
|
||||
copyright: 'Copyright 2024',
|
||||
title: 'City Traffic',
|
||||
tags: ['traffic', 'city', 'urban']
|
||||
}
|
||||
},
|
||||
{
|
||||
relativePath: 'ambience/nature/forest.wav',
|
||||
options: {
|
||||
author: 'Jane Smith',
|
||||
copyright: 'Copyright 2024',
|
||||
title: 'Forest Ambience',
|
||||
tags: ['forest', 'nature', 'birds']
|
||||
}
|
||||
},
|
||||
{
|
||||
relativePath: 'effects/explosion.wav',
|
||||
options: {
|
||||
author: 'John Doe',
|
||||
copyright: 'Copyright 2023',
|
||||
title: 'Big Explosion',
|
||||
tags: ['explosion', 'boom', 'loud']
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// Add files to database
|
||||
const files = [
|
||||
{
|
||||
absolutePath: path.join(libraryPath, 'ambience/city/traffic.wav'),
|
||||
relativePath: 'ambience/city/traffic.wav',
|
||||
fileName: 'traffic.wav',
|
||||
displayName: 'traffic',
|
||||
tags: ['traffic', 'city', 'urban'],
|
||||
categories: []
|
||||
},
|
||||
{
|
||||
absolutePath: path.join(libraryPath, 'ambience/nature/forest.wav'),
|
||||
relativePath: 'ambience/nature/forest.wav',
|
||||
fileName: 'forest.wav',
|
||||
displayName: 'forest',
|
||||
tags: ['forest', 'nature', 'birds'],
|
||||
categories: []
|
||||
},
|
||||
{
|
||||
absolutePath: path.join(libraryPath, 'effects/explosion.wav'),
|
||||
relativePath: 'effects/explosion.wav',
|
||||
fileName: 'explosion.wav',
|
||||
displayName: 'explosion',
|
||||
tags: ['explosion', 'boom', 'loud'],
|
||||
categories: []
|
||||
}
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
database.upsertFile({
|
||||
...file,
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null
|
||||
});
|
||||
}
|
||||
|
||||
searchService.rebuildIndex();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
database.close();
|
||||
await TestWavGenerator.cleanupTestLibrary(libraryPath);
|
||||
await fs.unlink(dbPath).catch(() => {});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should find files by filename', () => {
|
||||
const results = searchService.search('traffic');
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some(r => r.fileName === 'traffic.wav'));
|
||||
});
|
||||
|
||||
it('should find files by tag', () => {
|
||||
const results = searchService.search('forest');
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some(r => r.tags.includes('forest')));
|
||||
});
|
||||
|
||||
it('should find files by path', () => {
|
||||
const results = searchService.search('ambience');
|
||||
|
||||
assert.ok(results.length >= 2);
|
||||
assert.ok(results.every(r => r.relativePath.includes('ambience')));
|
||||
});
|
||||
|
||||
it('should return empty array for no matches', () => {
|
||||
const results = searchService.search('nonexistent');
|
||||
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
it('should perform fuzzy matching', () => {
|
||||
const results = searchService.search('trffic'); // typo
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some(r => r.fileName === 'traffic.wav'));
|
||||
});
|
||||
|
||||
it('should support author: filter', () => {
|
||||
const results = searchService.search('author:john');
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.every(r => {
|
||||
const metadata = tagService.readMetadata(r.absolutePath);
|
||||
return metadata.author?.toLowerCase().includes('john');
|
||||
}));
|
||||
});
|
||||
|
||||
it('should support copyright: filter', () => {
|
||||
const results = searchService.search('copyright:2024');
|
||||
|
||||
assert.ok(results.length >= 2);
|
||||
});
|
||||
|
||||
it('should combine regular search with filters', () => {
|
||||
const results = searchService.search('author:john explosion');
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some(r => r.fileName === 'explosion.wav'));
|
||||
});
|
||||
|
||||
it('should cache metadata for performance', () => {
|
||||
// First search should cache metadata
|
||||
searchService.search('author:john');
|
||||
|
||||
// Second search should use cache
|
||||
const results = searchService.search('author:john');
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
});
|
||||
|
||||
it('should handle empty query', () => {
|
||||
const results = searchService.search('');
|
||||
|
||||
// Empty query returns all files
|
||||
assert.strictEqual(results.length, 3);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only query', () => {
|
||||
const results = searchService.search(' ');
|
||||
|
||||
// Whitespace-only query is treated as empty, returns all files
|
||||
assert.strictEqual(results.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebuildIndex', () => {
|
||||
it('should update search index', () => {
|
||||
// Add a new file to database
|
||||
database.upsertFile({
|
||||
absolutePath: path.join(libraryPath, 'new.wav'),
|
||||
relativePath: 'new.wav',
|
||||
fileName: 'new.wav',
|
||||
displayName: 'new',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: null,
|
||||
tags: ['new'],
|
||||
categories: []
|
||||
});
|
||||
|
||||
searchService.rebuildIndex();
|
||||
|
||||
const results = searchService.search('new');
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some(r => r.fileName === 'new.wav'));
|
||||
});
|
||||
|
||||
it('should clear metadata cache', () => {
|
||||
// Cache some metadata
|
||||
searchService.search('author:john');
|
||||
|
||||
// Rebuild should clear cache
|
||||
searchService.rebuildIndex();
|
||||
|
||||
// Search again should work
|
||||
const results = searchService.search('author:john');
|
||||
assert.ok(results.length > 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/test/TEST_SUMMARY.ts
Normal file
69
src/test/TEST_SUMMARY.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Test Suite Summary
|
||||
* ===================
|
||||
*
|
||||
* Comprehensive test coverage for AudioSort application core services.
|
||||
* All tests use real WAV file generation and isolated test environments.
|
||||
*
|
||||
* Total Test Count: 67+ tests across 4 test files
|
||||
*
|
||||
* Test Files:
|
||||
* -----------
|
||||
*
|
||||
* 1. DatabaseService.test.ts (20 tests)
|
||||
* - File CRUD operations
|
||||
* - Tag and category management
|
||||
* - Duplicate detection
|
||||
* - Settings persistence
|
||||
*
|
||||
* 2. TagService.test.ts (14 tests)
|
||||
* - WAV metadata reading (author, copyright, title, rating)
|
||||
* - WAV metadata writing
|
||||
* - Tag normalization and application
|
||||
*
|
||||
* 3. LibraryService.test.ts (20 tests)
|
||||
* - Library scanning with metadata priority
|
||||
* - File operations (rename, move, delete)
|
||||
* - Temp file cleanup and recovery
|
||||
* - Metadata management without organizing
|
||||
*
|
||||
* 4. SearchService.test.ts (13 tests)
|
||||
* - Fuzzy search across filename, tags, path
|
||||
* - Advanced filters (author:, copyright:)
|
||||
* - Typo tolerance and similarity scoring
|
||||
* - Metadata caching and index rebuilding
|
||||
*
|
||||
* Running Tests:
|
||||
* --------------
|
||||
*
|
||||
* # Run all tests
|
||||
* npm test
|
||||
*
|
||||
* # Run specific service tests
|
||||
* npm run test:database
|
||||
* npm run test:tag
|
||||
* npm run test:library
|
||||
* npm run test:search
|
||||
*
|
||||
* Test Infrastructure:
|
||||
* --------------------
|
||||
*
|
||||
* - Framework: Node.js built-in test runner (node:test)
|
||||
* - TypeScript: tsx for runtime execution
|
||||
* - WAV Generation: WaveFile library with real audio data
|
||||
* - Test Isolation: Temporary databases and libraries per suite
|
||||
* - Audio Format: 16-bit PCM, 44100 Hz, sine wave (440 Hz)
|
||||
* - Metadata: WAV INFO chunks (INAM, IART, ICOP, IRTD, etc.)
|
||||
*
|
||||
* Key Features:
|
||||
* -------------
|
||||
*
|
||||
* ✓ Real WAV file generation with embedded metadata
|
||||
* ✓ Isolated test environments (no cross-contamination)
|
||||
* ✓ Comprehensive coverage of all service methods
|
||||
* ✓ Edge case testing (missing data, errors, duplicates)
|
||||
* ✓ Fast execution with minimal dependencies
|
||||
* ✓ No mocking - tests use actual service implementations
|
||||
*
|
||||
* For detailed documentation, see TEST_README.md
|
||||
*/
|
||||
245
src/test/TagService.test.ts
Normal file
245
src/test/TagService.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Comprehensive test suite for TagService.
|
||||
* Run with: node --import tsx --test src/test/TagService.test.ts
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { TagService } from '../main/services/TagService';
|
||||
import { DatabaseService } from '../main/services/DatabaseService';
|
||||
import { TestWavGenerator, getTempDbPath, getTempLibraryPath } from './testHelpers';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('TagService', () => {
|
||||
let tagService: TagService;
|
||||
let database: DatabaseService;
|
||||
let dbPath: string;
|
||||
let libraryPath: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = getTempDbPath();
|
||||
libraryPath = getTempLibraryPath();
|
||||
database = new DatabaseService(dbPath);
|
||||
database.initialize();
|
||||
tagService = new TagService(database);
|
||||
|
||||
await TestWavGenerator.createTestLibrary(libraryPath, [
|
||||
{
|
||||
relativePath: 'test.wav',
|
||||
options: {
|
||||
duration: 1,
|
||||
author: 'Test Author',
|
||||
copyright: 'Test Copyright',
|
||||
title: 'Test Title',
|
||||
rating: 3,
|
||||
tags: ['test', 'audio'],
|
||||
categories: ['AMBNatr']
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
testFilePath = path.join(libraryPath, 'test.wav');
|
||||
|
||||
// Add file to database
|
||||
database.upsertFile({
|
||||
absolutePath: testFilePath,
|
||||
relativePath: 'test.wav',
|
||||
fileName: 'test.wav',
|
||||
displayName: 'test',
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
size: 1024,
|
||||
durationMs: 1000,
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
checksum: 'test-checksum',
|
||||
tags: ['test'],
|
||||
categories: ['AMBNatr']
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
database.close();
|
||||
await TestWavGenerator.cleanupTestLibrary(libraryPath);
|
||||
await fs.unlink(dbPath).catch(() => {});
|
||||
});
|
||||
|
||||
describe('readMetadata', () => {
|
||||
it('should read author from WAV file', () => {
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.author, 'Test Author');
|
||||
});
|
||||
|
||||
it('should read copyright from WAV file', () => {
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.copyright, 'Test Copyright');
|
||||
});
|
||||
|
||||
it('should read title from WAV file', () => {
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.title, 'Test Title');
|
||||
});
|
||||
|
||||
it('should read rating from WAV file', () => {
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.rating, 3);
|
||||
});
|
||||
|
||||
it('should handle missing metadata gracefully', async () => {
|
||||
const emptyFilePath = path.join(libraryPath, 'empty.wav');
|
||||
await TestWavGenerator.writeTestWav(emptyFilePath, {});
|
||||
|
||||
const metadata = tagService.readMetadata(emptyFilePath);
|
||||
assert.strictEqual(metadata.author, undefined);
|
||||
assert.strictEqual(metadata.copyright, undefined);
|
||||
assert.strictEqual(metadata.title, undefined);
|
||||
assert.strictEqual(metadata.rating, undefined);
|
||||
});
|
||||
|
||||
it('should handle corrupted files gracefully', () => {
|
||||
const metadata = tagService.readMetadata('/nonexistent/file.wav');
|
||||
assert.deepStrictEqual(metadata, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeMetadataOnly', () => {
|
||||
it('should write author to WAV file', () => {
|
||||
tagService.writeMetadataOnly(testFilePath, {
|
||||
tags: ['test'],
|
||||
categories: ['AMBNatr'],
|
||||
author: 'New Author'
|
||||
});
|
||||
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.author, 'New Author');
|
||||
});
|
||||
|
||||
it('should write copyright to WAV file', () => {
|
||||
tagService.writeMetadataOnly(testFilePath, {
|
||||
tags: ['test'],
|
||||
categories: ['AMBNatr'],
|
||||
copyright: 'New Copyright'
|
||||
});
|
||||
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.copyright, 'New Copyright');
|
||||
});
|
||||
|
||||
it('should write title to WAV file', () => {
|
||||
tagService.writeMetadataOnly(testFilePath, {
|
||||
tags: ['test'],
|
||||
categories: ['AMBNatr'],
|
||||
title: 'New Title'
|
||||
});
|
||||
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.title, 'New Title');
|
||||
});
|
||||
|
||||
it('should write rating to WAV file', () => {
|
||||
tagService.writeMetadataOnly(testFilePath, {
|
||||
tags: ['test'],
|
||||
categories: ['AMBNatr'],
|
||||
rating: 5
|
||||
});
|
||||
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.rating, 5);
|
||||
});
|
||||
|
||||
it('should clear metadata when set to empty', () => {
|
||||
tagService.writeMetadataOnly(testFilePath, {
|
||||
tags: ['test'],
|
||||
categories: ['AMBNatr'],
|
||||
author: '',
|
||||
copyright: '',
|
||||
title: '',
|
||||
rating: 0
|
||||
});
|
||||
|
||||
const metadata = tagService.readMetadata(testFilePath);
|
||||
assert.strictEqual(metadata.author, undefined);
|
||||
assert.strictEqual(metadata.copyright, undefined);
|
||||
assert.strictEqual(metadata.title, undefined);
|
||||
assert.strictEqual(metadata.rating, undefined);
|
||||
});
|
||||
|
||||
it('should write tags to WAV file', () => {
|
||||
tagService.writeMetadataOnly(testFilePath, {
|
||||
tags: ['new', 'tags'],
|
||||
categories: ['AMBNatr']
|
||||
});
|
||||
|
||||
// Metadata write succeeded without throwing
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it('should write categories to WAV file', () => {
|
||||
tagService.writeMetadataOnly(testFilePath, {
|
||||
tags: ['test'],
|
||||
categories: ['AMBCity', 'AMBNatr']
|
||||
});
|
||||
|
||||
// Verify write succeeded
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTagging', () => {
|
||||
it('should update tags in database', () => {
|
||||
const files = database.listFiles();
|
||||
const fileId = files[0].id;
|
||||
|
||||
const updated = tagService.applyTagging(fileId, ['new', 'tags'], ['AMBNatr']);
|
||||
|
||||
assert.deepStrictEqual(updated.tags, ['new', 'tags']);
|
||||
});
|
||||
|
||||
it('should update categories in database', () => {
|
||||
const files = database.listFiles();
|
||||
const fileId = files[0].id;
|
||||
|
||||
const updated = tagService.applyTagging(fileId, ['test'], ['AMBCity', 'AMBNatr']);
|
||||
|
||||
assert.deepStrictEqual(updated.categories, ['AMBCity', 'AMBNatr']);
|
||||
});
|
||||
|
||||
it('should write metadata to WAV file', () => {
|
||||
const files = database.listFiles();
|
||||
const fileId = files[0].id;
|
||||
|
||||
tagService.applyTagging(fileId, ['new', 'tags'], ['AMBCity']);
|
||||
|
||||
// Verify it didn't throw
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it('should normalize tags by trimming whitespace', () => {
|
||||
const files = database.listFiles();
|
||||
const fileId = files[0].id;
|
||||
|
||||
const updated = tagService.applyTagging(fileId, [' tag1 ', ' tag2 '], ['AMBNatr']);
|
||||
|
||||
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
|
||||
});
|
||||
|
||||
it('should remove duplicate tags', () => {
|
||||
const files = database.listFiles();
|
||||
const fileId = files[0].id;
|
||||
|
||||
const updated = tagService.applyTagging(fileId, ['tag1', 'tag1', 'tag2'], ['AMBNatr']);
|
||||
|
||||
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
|
||||
});
|
||||
|
||||
it('should remove empty tags', () => {
|
||||
const files = database.listFiles();
|
||||
const fileId = files[0].id;
|
||||
|
||||
const updated = tagService.applyTagging(fileId, ['tag1', '', ' ', 'tag2'], ['AMBNatr']);
|
||||
|
||||
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
152
src/test/testHelpers.ts
Normal file
152
src/test/testHelpers.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Test helpers and utilities for creating test WAV files with metadata.
|
||||
*/
|
||||
import { WaveFile } from 'wavefile';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export class TestWavGenerator {
|
||||
/**
|
||||
* Creates a simple 1-second WAV file with metadata.
|
||||
*/
|
||||
public static createTestWav(options: {
|
||||
duration?: number;
|
||||
sampleRate?: number;
|
||||
bitDepth?: number;
|
||||
tags?: string[];
|
||||
categories?: string[];
|
||||
author?: string;
|
||||
copyright?: string;
|
||||
title?: string;
|
||||
rating?: number;
|
||||
} = {}): Buffer {
|
||||
const {
|
||||
duration = 1,
|
||||
sampleRate = 44100,
|
||||
bitDepth = 16,
|
||||
tags = [],
|
||||
categories = [],
|
||||
author,
|
||||
copyright,
|
||||
title,
|
||||
rating
|
||||
} = options;
|
||||
|
||||
// Generate simple sine wave data
|
||||
const numSamples = Math.floor(sampleRate * duration);
|
||||
const frequency = 440; // A4 note
|
||||
const samples = new Int16Array(numSamples);
|
||||
const amplitude = Math.pow(2, bitDepth - 1) - 1;
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const time = i / sampleRate;
|
||||
samples[i] = Math.floor(amplitude * 0.5 * Math.sin(2 * Math.PI * frequency * time));
|
||||
}
|
||||
|
||||
// Create WAV file
|
||||
const wav = new WaveFile();
|
||||
wav.fromScratch(1, sampleRate, String(bitDepth), samples);
|
||||
|
||||
// Add metadata using INFO chunks
|
||||
const wavWithTags = wav as WaveFile & {
|
||||
setTag?: (tag: string, value: string) => void;
|
||||
};
|
||||
|
||||
if (wavWithTags.setTag) {
|
||||
// Set tags and categories
|
||||
if (tags.length > 0) {
|
||||
wavWithTags.setTag('IKEY', tags.join('; '));
|
||||
wavWithTags.setTag('ISBJ', tags.join('; '));
|
||||
}
|
||||
|
||||
if (categories.length > 0) {
|
||||
wavWithTags.setTag('IGNR', categories.join('; '));
|
||||
wavWithTags.setTag('ICMT', categories.join('; '));
|
||||
wavWithTags.setTag('ISUB', categories.join('; '));
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
if (title) {
|
||||
wavWithTags.setTag('INAM', title);
|
||||
}
|
||||
|
||||
if (author) {
|
||||
wavWithTags.setTag('IART', author);
|
||||
}
|
||||
|
||||
if (copyright) {
|
||||
wavWithTags.setTag('ICOP', copyright);
|
||||
}
|
||||
|
||||
if (rating && rating > 0) {
|
||||
wavWithTags.setTag('IRTD', String(rating * 2));
|
||||
}
|
||||
|
||||
wavWithTags.setTag('ISFT', 'AudioSort Test Generator');
|
||||
}
|
||||
|
||||
return Buffer.from(wav.toBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a test WAV file to disk.
|
||||
*/
|
||||
public static async writeTestWav(
|
||||
filePath: string,
|
||||
options: Parameters<typeof TestWavGenerator.createTestWav>[0] = {}
|
||||
): Promise<void> {
|
||||
const buffer = this.createTestWav(options);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple test WAV files in a directory structure.
|
||||
*/
|
||||
public static async createTestLibrary(
|
||||
rootPath: string,
|
||||
files: Array<{
|
||||
relativePath: string;
|
||||
options?: Parameters<typeof TestWavGenerator.createTestWav>[0];
|
||||
}>
|
||||
): Promise<void> {
|
||||
await fs.mkdir(rootPath, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(rootPath, file.relativePath);
|
||||
await this.writeTestWav(fullPath, file.options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up a test directory.
|
||||
*/
|
||||
public static async cleanupTestLibrary(rootPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(rootPath, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to cleanup test library at ${rootPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary test database path.
|
||||
*/
|
||||
export function getTempDbPath(): string {
|
||||
return path.join(process.cwd(), 'test-data', `test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary test library path.
|
||||
*/
|
||||
export function getTempLibraryPath(): string {
|
||||
return path.join(process.cwd(), 'test-data', `test-lib-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for testing async operations.
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user