38 Commits

Author SHA1 Message Date
c6df01061a revert: restore RPM target 2025-11-12 07:13:00 +11:00
720fdf8946 fix: remove RPM target (requires rpmbuild on runner) 2025-11-12 07:12:25 +11:00
e3f4bfc7e2 fix: use OS labels to distinguish self-hosted runners 2025-11-12 07:07:03 +11:00
4d3d616574 fix: add GH_TOKEN to build steps for electron-builder 2025-11-12 07:04:52 +11:00
93d0da3496 chore: use self-hosted runner for Linux builds 2025-11-12 07:01:51 +11:00
761b1574e7 fix: remove snap target to avoid publishing issues 2025-11-12 06:56:30 +11:00
953c22bec3 chore: use self-hosted runner for Windows builds 2025-11-12 06:54:40 +11:00
acf4b8d8b7 chore: add author website 2025-11-12 06:43:48 +11:00
fe38e7e8f2 fix: add author email and disable snap publishing 2025-11-12 06:42:57 +11:00
e16abd7370 fix: add PNG icon for Linux builds 2025-11-12 06:38:44 +11:00
9ade1cc7dd fix: only build on tags, not every push 2025-11-12 06:37:05 +11:00
7ecb65153c fix: use icon.ico for Linux builds 2025-11-12 06:35:18 +11:00
65dadc9e3f chore: consolidate to single build workflow 2025-11-12 06:34:22 +11:00
9e487fed7b fix: update dependency installation method in build workflow 2025-11-12 06:29:45 +11:00
e16b069ddd chore: bump version to 0.5.2 2025-11-12 06:28:24 +11:00
947a510646 linux building 2025-11-12 06:27:39 +11:00
343efc18ed feat: add external import with MD5 deduplication and drive detection
- Import from folder or removable drive via File menu
- MD5 checksum-based duplicate detection from audio content
- Windows drive detection with removable/fixed type labels
- Drive accessibility verification before listing
- Import files copied to _Imports/<date>/ folders
- Comprehensive logging for import debugging
2025-11-12 06:19:28 +11:00
c2df7198bd feat: implement drive import functionality 2025-11-12 06:18:06 +11:00
69d98c1f1a 0.4.0 2025-11-12 04:13:18 +11:00
ed3d9b60df chore: refresh app icon metadata 2025-11-12 04:12:50 +11:00
18a2d3ee26 0.3.2 2025-11-12 04:06:18 +11:00
cf6c14c161 docs: refresh readme and add app icons 2025-11-12 04:05:24 +11:00
524bbdce8a docs: update README with new feature 2025-11-12 02:56:50 +11:00
3569a46ebb 0.3.1 2025-11-12 02:46:33 +11:00
b4bfbd3ef6 chore: update dependencies and add patch-package for better-sqlite3
feat: implement just-split category filter and enhance metadata parsing
fix: improve type imports and variable handling in services
2025-11-12 02:45:07 +11:00
c33371b206 fix: update dependencies 2025-11-12 00:02:15 +11:00
4639494b9d chore: bump version to 0.3.0 2025-11-11 23:59:12 +11:00
7308e3c97c fix file by id in the parent field 2025-11-11 23:53:49 +11:00
5b2090c102 feat: add fade in/out functionality for audio segments with UI controls 2025-11-11 23:46:26 +11:00
e2017fc6ea removed text 2025-11-11 11:42:55 +11:00
e9c679dfaf - Updated package.json to include the UCS.csv file under the 'data' directory instead of the root.
- Modified MainApp.ts to resolve the path for UCS.csv from the 'data' directory.
- Deleted the unused test.wav file to clean up the repository.
2025-11-11 11:01:43 +11:00
d0f7b0fe18 chore: remove failing test harness 2025-11-11 10:56:41 +11:00
db1db50302 docs: update README with waveform editor features and new screenshot 2025-11-11 10:39:44 +11:00
f18fd4155e chore: bump version to 0.2.0 with audio splitting and waveform editing features 2025-11-11 10:35:52 +11:00
f4bea5e3fd feat: enhance TagService to improve tag extraction and fallback logic;
update TagEditor for category filtering and styling;
add middle click zoom reset in WaveformEditorCanvas;
refine app.css for layout adjustments
2025-11-11 10:31:32 +11:00
65ab2776eb feat: enhance TagEditor to support primary category selection and reorder categories 2025-11-11 08:25:36 +11:00
6515a30a0e feat: enhance waveform editor with playback controls and segment interaction
- Added onPlayFromCursor and playbackCursorMs props to WaveformEditorCanvas for playback control.
- Implemented visual feedback for segment selection and hover states, including color adjustments and labels.
- Introduced smooth cursor animation for better user experience during interaction.
- Enhanced segment metadata handling in types and IPC for better integration with the library.
- Added focusOnFile method in LibraryStore to manage file selection and refresh.
- Updated styles for edit mode, including new classes for waveform and playback controls.
- Introduced new IPC channel for splitting audio files and updated related interfaces.
- Added parentFileId to AudioFileSummary for tracking source files.
- Implemented tests for tagging service to ensure metadata integrity and tag preservation.
- Created scrubWorklet and assets type definitions for future audio processing features.
2025-11-11 08:19:19 +11:00
781187c427 feat: implement audio file splitting functionality
- Added `splitFile` method to `LibraryStore` for splitting audio files into segments.
- Introduced `SplitSegmentRequest` and `SegmentMetadataInput` interfaces in models for segment metadata handling.
- Created `EditModePanel` component for editing segments, including waveform visualization and metadata assignment.
- Developed `WaveformEditorCanvas` for interactive waveform editing, supporting segment creation and resizing.
- Enhanced metadata handling in IPC with new fields for author and copyright.
- Updated styles for new components and improved UI interactions.
2025-11-11 02:45:34 +11:00
43 changed files with 4642 additions and 2198 deletions

89
.github/workflows/build-all.yml vendored Normal file
View File

@@ -0,0 +1,89 @@
name: Build All Platforms
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-windows:
runs-on: [self-hosted, windows]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Build Windows
run: npm run dist:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows-installer
path: release/*.exe
build-linux:
runs-on: [self-hosted, linux]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Build Linux
run: npm run dist:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux artifacts
uses: actions/upload-artifact@v4
with:
name: linux-packages
path: |
release/*.AppImage
release/*.deb
release/*.rpm
release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows-installer
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
name: linux-packages
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
*.exe
*.AppImage
*.deb
*.rpm
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,29 +0,0 @@
name: Build/release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js and NPM
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Build/release Electron app
uses: samuelmeuli/action-electron-builder@v1
with:
github_token: ${{ secrets.github_token }}
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
args: --win

1
.gitignore vendored
View File

@@ -64,3 +64,4 @@ test-data/
*.tmp
*.temp
.cache/
AudioSort.code-workspace

View File

@@ -1,7 +1,7 @@
# AudioSort
[![Build/release](https://github.com/litruv/AudioSort/actions/workflows/build.yml/badge.svg)](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support.
A desktop application for organizing, tagging, and managing WAV audio files with Universal Category System (UCS) support, featuring advanced waveform editing and audio splitting capabilities.
![AudioSort Interface](repoimages/interface.png)
@@ -16,6 +16,7 @@ A desktop application for organizing, tagging, and managing WAV audio files with
### Powerful Search & Organization
- Fuzzy search across filenames, tags, categories, and metadata
- Filter by category or view untagged files
- Quick "JUST SPLIT" filter surfaces freshly generated segments for rapid QC
- Multi-select support for batch operations
- Custom naming with automatic conflict resolution
@@ -27,27 +28,24 @@ A desktop application for organizing, tagging, and managing WAV audio files with
![Multi-file Editing](repoimages/multiediting.png)
### Waveform Editor
- Visual waveform editing with playback controls
- Segment-based audio splitting and trimming
- Real-time preview with zoom and pan controls
- Export individual segments with automatic naming
- Non-destructive editing workflow
![Waveform Editor](repoimages/editor.png)
### Beautiful Interface
- Real-time waveform visualization with dynamic color-coding based on RMS levels
- Waveform editor for splitting and trimming audio
- Smooth animations and polished drag-and-drop interactions
- Responsive file list with keyboard navigation (Arrow keys, Ctrl+A)
- Dark theme optimized for long sessions
![Smooth UI Demo](repoimages/smoothui.gif)
### Smart Library Management
- Automatic library scanning and indexing
- MD5 checksums for duplicate detection
- Metadata caching for instant access
- SQLite database for fast queries and reliable storage
## Technology Stack
- **Frontend**: React 18, TypeScript, Vite
- **Backend**: Electron 29, Node.js
- **Database**: better-sqlite3 with WAL mode
- **Audio Processing**: WaveFile, music-metadata
- **Search**: Fuse.js for fuzzy search
## Installation
@@ -119,8 +117,8 @@ AudioSort/
│ │ └── hooks/ # Custom React hooks
│ ├── preload/ # Electron preload scripts
│ └── shared/ # Shared types and IPC definitions
├── data/ # User data directory (created at runtime)
── UCS.csv # Universal Category System catalog
├── data/
│ └── UCS.csv # Universal Category System catalog
└── repoimages/ # Documentation assets
```
@@ -131,21 +129,11 @@ AudioSort/
npm run dev # Start development server with hot reload
npm run build # Build renderer and main process
npm run lint # Type-check with TypeScript
npm run test # Run test suite
npm run pack # Package without installer
npm run dist # Create distributable
npm run dist:win # Create Windows installer
```
### Testing
```bash
npm test # Run all tests
npm run test:database # Database service tests
npm run test:library # Library service tests
npm run test:search # Search service tests
npm run test:tag # Tag service tests
```
## Configuration
### Build Configuration
@@ -158,4 +146,3 @@ Edit `package.json` `build` section to customize:
### Database Location
User data is stored in:
- **Windows**: `%APPDATA%/AudioSort/`
- **Linux**: `~/.config/AudioSort/`

View File

@@ -1,219 +0,0 @@
# AudioSort Test Suite
Comprehensive test suite for AudioSort application using real WAV files with embedded metadata.
## Running Tests
### Run all tests
```powershell
npm test
```
### Run specific test suites
```powershell
# Tag Service tests (metadata read/write)
npm run test:tag
# Library Service tests (scanning, organizing, file operations)
npm run test:library
# Search Service tests (fuzzy search, filters)
node --import tsx --test src/test/SearchService.test.ts
```
### Run individual test files
```powershell
node --import tsx --test src/test/DatabaseService.test.ts
node --import tsx --test src/test/TagService.test.ts
node --import tsx --test src/test/LibraryService.test.ts
node --import tsx --test src/test/SearchService.test.ts
```
## Test Coverage
### DatabaseService Tests (20+ tests)
- **File Management**: upsertFile, listFiles, getFileById, updateFileLocation, deleteFile
- **Tagging Operations**: updateTagging, updateCustomName
- **Category Management**: upsertCategory, getCategoryById, listCategories
- **Duplicate Detection**: listDuplicateGroups by checksum
- **Settings Persistence**: setSetting, getSettings
- Edge cases: Missing files, null values, duplicate checksums
### TagService Tests (14 tests)
- **readMetadata**: Reading author, copyright, title, rating from WAV files
- **writeMetadataOnly**: Writing metadata to WAV INFO chunks
- **applyTagging**: Updating tags/categories in database and WAV files
- Edge cases: Missing metadata, corrupted files, empty values, normalization
### LibraryService Tests (20+ tests)
- **scanLibrary**: File discovery, metadata extraction, temp file cleanup
- **listFiles**: File listing with metadata
- **renameFile**: File renaming on disk and in database
- **moveFile**: Moving files between directories
- **updateCustomName**: Custom name management
- **updateFileMetadata**: Metadata updates without organizing
- **readFileMetadata**: Reading embedded WAV metadata
- **listMetadataSuggestions**: Aggregating author/copyright suggestions
- **deleteFiles**: File deletion from disk and database
- **cleanupTempFiles**: Orphaned temp file recovery
### SearchService Tests (13 tests)
- **search**: Fuzzy search by filename, tags, path
- **Advanced filters**: author:, copyright: field-specific searches
- **Metadata caching**: Performance optimization
- **rebuildIndex**: Index updates after database changes
- Edge cases: Empty queries, typos, no matches
## Test Data
Tests use the `TestWavGenerator` class to create real WAV files with:
- Configurable duration, sample rate, bit depth
- Embedded metadata (author, copyright, title, rating)
- Tags and UCS categories
- Sine wave audio data (440 Hz)
Example test file creation:
```typescript
await TestWavGenerator.writeTestWav('test.wav', {
duration: 1,
sampleRate: 44100,
bitDepth: 16,
author: 'Test Author',
copyright: 'Test Copyright',
title: 'Test Title',
rating: 3,
tags: ['test', 'audio'],
categories: ['AMBNatr']
});
```
## Test Isolation
Each test suite:
1. Creates temporary database and library directories
2. Generates fresh test WAV files
3. Initializes services with isolated instances
4. Cleans up all resources after tests complete
Temporary files are created in `test-data/` and automatically removed.
## Testing Strategy
### Unit Tests
- Test individual service methods in isolation
- Verify correct behavior with valid inputs
- Handle edge cases and error conditions
### Integration Tests
- Test interactions between services (LibraryService + TagService + SearchService)
- Verify database updates reflect in search results
- Ensure file system operations complete successfully
### Real Data Tests
- Use actual WAV files with embedded metadata
- Test metadata round-trip (write then read)
- Verify file integrity after operations
## Extending Tests
### Adding New Test Cases
1. **For TagService**:
```typescript
it('should handle new metadata field', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: [],
categories: [],
newField: 'value'
});
const metadata = tagService.readMetadata(testFilePath);
assert.strictEqual(metadata.newField, 'value');
});
```
2. **For LibraryService**:
```typescript
it('should handle new operation', async () => {
await libraryService.scanLibrary();
const files = libraryService.listFiles();
// Perform operation
await libraryService.newOperation(files[0].id);
// Verify results
const updated = libraryService.listFiles();
assert.ok(/* verification */);
});
```
3. **For SearchService**:
```typescript
it('should support new filter', () => {
const results = searchService.search('newfilter:value');
assert.ok(results.length > 0);
assert.ok(/* filter applied correctly */);
});
```
## Troubleshooting
### Tests hang or timeout
- Check for leaked file handles (ensure database.close() in afterEach)
- Verify temp files are being cleaned up
- Increase test timeout if needed
### File not found errors
- Ensure library path is created before writing test files
- Check file paths use correct separators for OS
- Verify temp directory permissions
### Metadata not persisting
- Confirm WAV file format is correct (use WaveFile library)
- Check INFO chunk tags are supported
- Verify file is written to disk before reading
### Search tests fail
- Rebuild search index after database changes
- Clear metadata cache when needed
- Check Fuse.js scoring thresholds
## Performance Considerations
- Tests create real WAV files (may be slower than mocks)
- Each test suite runs independently with fresh data
- Cleanup operations ensure no disk space accumulation
- Test data directory can be cleared manually if needed: `rm -rf test-data`
## CI/CD Integration
Tests can be run in CI/CD pipelines:
```yaml
# GitHub Actions example
- name: Run tests
run: npm test
```
Tests require:
- Node.js 18+ (for node:test runner)
- File system write access
- Sufficient disk space for test WAV files (~1-2 MB per test run)
## Debugging Tests
Enable verbose output:
```powershell
NODE_OPTIONS='--test-reporter=spec' npm test
```
Run specific test:
```powershell
node --import tsx --test --test-name-pattern="should read author" src/test/TagService.test.ts
```
Debug with inspector:
```powershell
node --import tsx --test --inspect-brk src/test/TagService.test.ts
```

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -1,7 +1,12 @@
{
"name": "audio-sort",
"version": "0.1.1",
"description": "Electron audio sorting application with fuzzy search, tagging, and library management for WAV files.",
"version": "0.5.2",
"description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
"author": {
"name": "litruv",
"email": "litruv@example.com",
"url": "https://lit.ruv.wtf"
},
"main": "dist/main/main/index.js",
"type": "commonjs",
"scripts": {
@@ -17,59 +22,74 @@
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder",
"dist:win": "npm run build && electron-builder --win",
"test": "node --import tsx --test src/test/DatabaseService.test.ts src/test/TagService.test.ts src/test/LibraryService.test.ts src/test/SearchService.test.ts",
"test:tag": "node --import tsx --test src/test/TagService.test.ts",
"test:library": "node --import tsx --test src/test/LibraryService.test.ts",
"test:database": "node --import tsx --test src/test/DatabaseService.test.ts",
"test:search": "node --import tsx --test src/test/SearchService.test.ts"
"dist:linux": "npm run build && electron-builder --linux",
"postinstall": "patch-package"
},
"dependencies": {
"better-sqlite3": "^9.4.5",
"csv-parse": "^5.5.5",
"fast-glob": "^3.3.2",
"fuse.js": "^6.6.2",
"music-metadata": "^10.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"better-sqlite3": "^12.4.1",
"csv-parse": "^6.1.0",
"fast-glob": "^3.3.3",
"fuse.js": "^7.1.0",
"music-metadata": "^11.10.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"standardized-audio-context": "^25.3.77",
"wavefile": "^11.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.12.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.3",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"electron": "^29.4.4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.1.2",
"electron-builder": "^26.0.12",
"patch-package": "^8.0.1",
"tree-kill": "^1.2.2",
"tsx": "^4.15.7",
"typescript": "^5.3.3",
"vite": "^5.1.6"
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vite": "^7.2.2"
},
"build": {
"appId": "com.audiosort.app",
"productName": "AudioSort",
"files": [
"dist/**/*",
"UCS.csv"
"data/**/*"
],
"directories": {
"output": "release"
},
"win": {
"target": ["nsis"],
"target": [
"nsis"
],
"icon": "build/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"linux": {
"target": [
"AppImage",
"deb",
"rpm"
],
"icon": "build/icon.png",
"category": "AudioVideo",
"description": "Audio sorting and management application",
"mimeTypes": [
"audio/wav",
"audio/x-wav"
]
},
"extraResources": [
{
"from": "UCS.csv",
"to": "UCS.csv"
"from": "data/UCS.csv",
"to": "data/UCS.csv"
}
]
}

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/better-sqlite3/src/better_sqlite3.cpp b/node_modules/better-sqlite3/src/better_sqlite3.cpp
index 024bb4f..28a4d55 100644
--- a/node_modules/better-sqlite3/src/better_sqlite3.cpp
+++ b/node_modules/better-sqlite3/src/better_sqlite3.cpp
@@ -46,7 +46,7 @@ class Backup;
#include "objects/statement-iterator.cpp"
NODE_MODULE_INIT(/* exports, context */) {
- v8::Isolate* isolate = context->GetIsolate();
+ v8::Isolate* isolate = v8::Isolate::GetCurrent();
v8::HandleScope scope(isolate);
Addon::ConfigureURI();

BIN
repoimages/editor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -3,7 +3,7 @@ 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 { SplitSegmentRequest, TagUpdatePayload } from '../shared/models';
import { DatabaseService } from './services/DatabaseService';
import { LibraryService } from './services/LibraryService';
import { SearchService } from './services/SearchService';
@@ -41,21 +41,27 @@ export class MainApp {
this.searchService
);
const catalogPath = this.resolveResourcePath('UCS.csv');
const catalogPath = this.resolveResourcePath(path.join('data', 'UCS.csv'));
await this.libraryService.ensureCategoriesLoaded(catalogPath);
await this.libraryService.scanLibrary();
this.searchService.rebuildIndex();
this.registerIpcHandlers();
this.createMenu();
await this.createMenu();
this.createWindow();
}
/**
* Creates the application menu.
*/
private createMenu(): void {
private async createMenu(): Promise<void> {
const isMac = process.platform === 'darwin';
const drives = await this.listAvailableDrives();
const driveSubmenu: Electron.MenuItemConstructorOptions[] = drives.map((drive) => ({
label: drive.label,
click: () => this.mainWindow?.webContents.send('import-from-drive', drive.path)
}));
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac ? [{
@@ -79,6 +85,16 @@ export class MainApp {
{
label: 'File',
submenu: [
{
label: 'Import From Folder...',
accelerator: isMac ? 'Cmd+Shift+I' : 'Ctrl+Shift+I',
click: () => this.mainWindow?.webContents.send('import-from-folder')
},
{
label: 'Import From Drive',
submenu: driveSubmenu.length > 0 ? driveSubmenu : [{ label: 'No drives available', enabled: false }]
},
{ type: 'separator' as const },
{
label: 'Rescan Library',
accelerator: isMac ? 'Cmd+R' : 'Ctrl+R',
@@ -132,6 +148,8 @@ export class MainApp {
ipcMain.removeHandler(IPC_CHANNELS.settingsGet);
ipcMain.removeHandler(IPC_CHANNELS.settingsSetLibrary);
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectLibrary);
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectImportFolder);
ipcMain.removeHandler(IPC_CHANNELS.systemListDrives);
ipcMain.removeHandler(IPC_CHANNELS.libraryScan);
ipcMain.removeHandler(IPC_CHANNELS.libraryList);
ipcMain.removeHandler(IPC_CHANNELS.libraryRename);
@@ -141,7 +159,9 @@ export class MainApp {
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
ipcMain.removeHandler(IPC_CHANNELS.libraryImport);
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
ipcMain.removeHandler(IPC_CHANNELS.searchQuery);
@@ -186,16 +206,18 @@ export class MainApp {
this.mainWindow = null;
});
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VITE_DEV_SERVER_URL;
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);
});
this.mainWindow
.loadFile(rendererIndex)
.catch((error: unknown) => console.error('Failed to load renderer bundle', error));
}
}
@@ -220,8 +242,22 @@ export class MainApp {
return result.canceled ? null : result.filePaths[0] ?? null;
});
ipcMain.handle(IPC_CHANNELS.dialogSelectImportFolder, 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.systemListDrives, async () => this.listAvailableDrives());
ipcMain.handle(IPC_CHANNELS.libraryScan, async () => this.requireLibrary().scanLibrary());
ipcMain.handle(IPC_CHANNELS.libraryList, async () => this.requireLibrary().listFiles());
ipcMain.handle(IPC_CHANNELS.libraryGetById, async (_event: IpcMainInvokeEvent, fileId: number) =>
this.requireLibrary().getFileById(fileId)
);
ipcMain.handle(IPC_CHANNELS.libraryDuplicates, async () => this.requireLibrary().listDuplicates());
ipcMain.handle(IPC_CHANNELS.searchQuery, async (_event: IpcMainInvokeEvent, query: string) =>
this.requireSearch().search(query)
@@ -239,7 +275,11 @@ export class MainApp {
ipcMain.handle(
IPC_CHANNELS.libraryOrganize,
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) =>
async (
_event: IpcMainInvokeEvent,
fileId: number,
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
) =>
this.requireLibrary().organizeFile(fileId, metadata)
);
@@ -257,6 +297,12 @@ export class MainApp {
this.requireLibrary().deleteFiles(fileIds)
);
ipcMain.handle(
IPC_CHANNELS.librarySplit,
async (_event: IpcMainInvokeEvent, fileId: number, segments: SplitSegmentRequest[]) =>
this.requireLibrary().splitFile(fileId, segments)
);
ipcMain.handle(IPC_CHANNELS.libraryBuffer, async (_event: IpcMainInvokeEvent, fileId: number) =>
this.requireLibrary().getAudioBuffer(fileId)
);
@@ -283,9 +329,26 @@ export class MainApp {
ipcMain.handle(
IPC_CHANNELS.libraryUpdateMetadata,
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) =>
async (
_event: IpcMainInvokeEvent,
fileId: number,
metadata: { author?: string | null; copyright?: string | null; rating?: number }
) =>
this.requireLibrary().updateFileMetadata(fileId, metadata)
);
ipcMain.handle(IPC_CHANNELS.libraryImport, async (_event: IpcMainInvokeEvent, payload: unknown) => {
if (!Array.isArray(payload)) {
throw new Error('Import request must provide an array of source paths.');
}
const sources = payload
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
if (sources.length === 0) {
return { imported: [], skipped: [], failed: [] };
}
return this.requireLibrary().importExternalSources(sources);
});
}
/**
@@ -340,6 +403,86 @@ export class MainApp {
return app.getAppPath();
}
private async listAvailableDrives(): Promise<Array<{ path: string; label: string }>> {
if (process.platform === 'win32') {
try {
const { exec } = await import('node:child_process');
const { promisify } = await import('node:util');
const execAsync = promisify(exec);
// Query Windows Management Instrumentation for drive info
const { stdout } = await execAsync('wmic logicaldisk get deviceid,drivetype', {
timeout: 5000,
windowsHide: true
});
const lines = stdout.trim().split('\n').slice(1); // Skip header
const drives: Array<{ path: string; type: string }> = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Parse "C: 3" format (DeviceID and DriveType)
const match = trimmed.match(/^([A-Z]:)\s+(\d+)$/);
if (!match) continue;
const [, deviceId, driveType] = match;
const drivePath = `${deviceId}\\`;
// Verify drive is actually accessible before including it
try {
await fs.promises.access(drivePath);
} catch {
continue; // Skip if not accessible (unplugged/ejected)
}
// DriveType: 2=Removable, 3=Local Fixed, 4=Network, 5=CD-ROM, 6=RAM Disk
let label = drivePath;
if (driveType === '2') {
label = `${drivePath} (Removable)`;
}
drives.push({ path: drivePath, type: label });
}
return drives.sort((a, b) => a.path.localeCompare(b.path)).map((d) => ({ path: d.path, label: d.type }));
} catch (error) {
console.warn('Failed to query drive types via wmic, falling back to simple enumeration', error);
// Fallback to basic enumeration
const drives: { path: string; label: string }[] = [];
const probes: Promise<void>[] = [];
for (let code = 65; code <= 90; code += 1) {
const letter = String.fromCharCode(code);
const drivePath = `${letter}:\\`;
const probe = fs.promises
.access(drivePath)
.then(() => {
drives.push({ path: drivePath, label: drivePath });
})
.catch(() => undefined);
probes.push(probe);
}
await Promise.all(probes);
return drives.sort((a, b) => a.path.localeCompare(b.path));
}
}
if (process.platform === 'darwin') {
try {
const entries = await fs.promises.readdir('/Volumes', { withFileTypes: true });
const volumes = entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({ path: path.join('/Volumes', entry.name), label: path.join('/Volumes', entry.name) }));
return volumes.length > 0 ? volumes : [{ path: '/', label: '/' }];
} catch {
return [{ path: '/', label: '/' }];
}
}
return [{ path: '/', label: '/' }];
}
private buildSearchBases(): string[] {
const bases = new Set<string>();
const appPath = app.getAppPath();

View File

@@ -33,6 +33,8 @@ export interface FileRecordInput {
tags?: string[];
/** Optional category payload (stored as JSON string). */
categories?: string[];
/** Optional parent file reference when generated from another file. */
parentFileId?: number | null;
}
export interface FileRecordRow extends AudioFileSummary {}
@@ -89,7 +91,8 @@ export class DatabaseService {
bit_depth,
checksum,
tags_json,
categories_json
categories_json,
parent_file_id
) VALUES (
@absolutePath,
@relativePath,
@@ -103,7 +106,8 @@ export class DatabaseService {
@bitDepth,
@checksum,
@tagsJson,
@categoriesJson
@categoriesJson,
@parentFileId
)
ON CONFLICT(absolute_path) DO UPDATE SET
library_relative_path = excluded.library_relative_path,
@@ -117,7 +121,8 @@ export class DatabaseService {
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
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END,
parent_file_id = CASE WHEN excluded.parent_file_id IS NOT NULL THEN excluded.parent_file_id ELSE files.parent_file_id END
RETURNING *`
);
@@ -134,7 +139,8 @@ export class DatabaseService {
bitDepth: record.bitDepth,
checksum: record.checksum,
tagsJson: JSON.stringify(record.tags ?? []),
categoriesJson: JSON.stringify(record.categories ?? [])
categoriesJson: JSON.stringify(record.categories ?? []),
parentFileId: record.parentFileId ?? null
}) as DbRow | undefined;
if (!row) {
@@ -145,22 +151,35 @@ export class DatabaseService {
}
/**
* Updates the stored tags and categories for a file.
* Updates the stored tags and/or categories for a file.
* When a field is omitted it remains unchanged.
*/
public updateTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
public updateTagging(fileId: number, tags?: string[], categories?: string[]): AudioFileSummary {
const connection = this.requireDb();
const updates: string[] = [];
const parameters: Record<string, unknown> = { id: fileId };
if (tags !== undefined) {
updates.push('tags_json = @tags');
parameters.tags = JSON.stringify(tags);
}
if (categories !== undefined) {
updates.push('categories_json = @categories');
parameters.categories = JSON.stringify(categories);
}
if (updates.length === 0) {
return this.getFileById(fileId);
}
const statement = connection.prepare(
`UPDATE files
SET tags_json = @tags,
categories_json = @categories
SET ${updates.join(', ')}
WHERE id = @id
RETURNING *`
);
const row = statement.get({
id: fileId,
tags: JSON.stringify(tags),
categories: JSON.stringify(categories)
}) as DbRow | undefined;
const row = statement.get(parameters) as DbRow | undefined;
if (!row) {
throw new Error(`File with id ${fileId} not found`);
}
@@ -408,6 +427,7 @@ export class DatabaseService {
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,
parentFileId: typeof row.parent_file_id === 'number' ? (row.parent_file_id as number) : 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
@@ -444,7 +464,8 @@ export class DatabaseService {
bit_depth INTEGER,
checksum TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
categories_json TEXT NOT NULL DEFAULT '[]'
categories_json TEXT NOT NULL DEFAULT '[]',
parent_file_id INTEGER REFERENCES files(id)
);
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);
@@ -478,6 +499,7 @@ export class DatabaseService {
this.addColumnIfMissing(connection, 'files', 'created_at', 'INTEGER');
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
// Create checksum index after ensuring column exists
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');

View File

@@ -4,7 +4,18 @@ import { createHash } from 'node:crypto';
import path from 'node:path';
import fg from 'fast-glob';
import { parse } from 'csv-parse/sync';
import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, TagUpdatePayload } from '../../shared/models';
import {
AppSettings,
AudioBufferPayload,
AudioFileSummary,
CategoryRecord,
ImportFailureEntry,
ImportSkipEntry,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
} from '../../shared/models';
import { DatabaseService, FileRecordInput } from './DatabaseService';
import { SettingsService } from './SettingsService';
import { TagService } from './TagService';
@@ -12,6 +23,15 @@ import { SearchService } from './SearchService';
import { WaveFile } from 'wavefile';
import { OrganizationService } from './OrganizationService';
type MusicMetadataParser = (path: string, options?: { duration?: boolean }) => Promise<{
format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null };
common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown };
}>;
type MusicMetadataNamespace = Partial<{ parseFile: MusicMetadataParser }> & {
default?: Partial<{ parseFile: MusicMetadataParser }>;
};
interface CsvCategoryRow {
Category: string;
SubCategory: string;
@@ -31,7 +51,8 @@ export class LibraryService {
private readonly organization: OrganizationService;
private metadataSuggestionCache: { authors: Set<string> } | null = null;
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
private offlineAudioContextCtor: (new (channelCount: number, length: number, sampleRate: number) => any) | null = null;
private musicMetadataParseFile: MusicMetadataParser | null = null;
public constructor(
private readonly database: DatabaseService,
private readonly settings: SettingsService,
@@ -84,11 +105,12 @@ export class LibraryService {
this.resetMetadataSuggestionsCache();
this.waveformPreviewCache.clear();
const cleanedTempFiles = await this.cleanupTempFiles();
await this.cleanupTempFiles();
const libraryRoot = this.settings.ensureLibraryPath();
const existing = this.database.listFiles();
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const));
const discoveredByPath = new Map<string, AudioFileSummary>();
const pattern = ['**/*.wav', '**/*.wave'];
const absolutePaths = await fg(pattern, {
@@ -117,9 +139,29 @@ export class LibraryService {
const knownFile = knownByPath ?? knownByChecksum ?? null;
const wasKnown = knownFile !== null;
// Read embedded WAV metadata (author, copyright, rating, title)
// Read embedded WAV metadata (author, copyright, rating, title, parentId)
const embeddedMetadata = this.tagService.readMetadata(absolutePath);
let parentFileId = knownFile?.parentFileId ?? null;
// First try embedded metadata parentId
if (parentFileId === null && embeddedMetadata.parentId !== undefined) {
parentFileId = embeddedMetadata.parentId;
}
// Fall back to filename pattern matching for segments
if (parentFileId === null) {
const segmentMatch = fileName.match(/^(.*)_segment\d+(\.[^.]+)$/i);
if (segmentMatch) {
const parentFileName = `${segmentMatch[1]}${segmentMatch[2]}`;
const parentAbsolutePath = path.join(path.dirname(absolutePath), parentFileName);
const parentRecord = existingByPath.get(parentAbsolutePath) ?? discoveredByPath.get(parentAbsolutePath) ?? null;
if (parentRecord) {
parentFileId = parentRecord.id;
}
}
}
const record: FileRecordInput = {
absolutePath,
relativePath,
@@ -133,14 +175,36 @@ export class LibraryService {
bitDepth: metadata.bitDepth,
checksum,
tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []),
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? [])
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []),
parentFileId
};
const upserted = this.database.upsertFile(record);
// Update custom name from embedded title if present, otherwise keep existing
const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
if (customName !== upserted.customName) {
this.database.updateCustomName(upserted.id, customName);
const finalRecord = customName !== upserted.customName
? this.database.updateCustomName(upserted.id, customName)
: upserted;
existingByPath.set(absolutePath, finalRecord);
if (checksum) {
existingByChecksum.set(checksum, finalRecord);
}
discoveredByPath.set(absolutePath, finalRecord);
if (parentFileId !== null) {
const embeddedParent = embeddedMetadata.parentId ?? null;
if (embeddedParent === null || embeddedParent !== parentFileId) {
this.tagService.writeMetadataOnly(absolutePath, {
tags: finalRecord.tags,
categories: finalRecord.categories,
title: customName ?? embeddedMetadata.title ?? finalRecord.displayName,
author: embeddedMetadata.author ?? null,
rating: embeddedMetadata.rating,
copyright: embeddedMetadata.copyright ?? null,
parentId: parentFileId
});
}
}
if (wasKnown) {
@@ -171,6 +235,20 @@ export class LibraryService {
return this.database.listFiles();
}
/**
* Attempts to resolve a file summary by id. Returns null when the record no longer exists.
*/
public getFileById(fileId: number): AudioFileSummary | null {
try {
return this.database.getFileById(fileId);
} catch (error) {
if (error instanceof Error) {
console.warn(`Failed to resolve file by id ${fileId}:`, error.message);
}
return null;
}
}
/**
* Returns groups of files that have identical checksums (potential duplicates).
*/
@@ -225,6 +303,138 @@ export class LibraryService {
return cleanedCount;
}
/**
* Imports external WAV files from the provided sources, copying them into the library while
* skipping duplicates based on the audio checksum. Imported files land under `_Imports/<date>`.
*/
public async importExternalSources(sourcePaths: string[]): Promise<LibraryImportResult> {
// eslint-disable-next-line no-console -- Log import start for debugging.
console.log('Starting import from:', sourcePaths);
if (!Array.isArray(sourcePaths) || sourcePaths.length === 0) {
return { imported: [], skipped: [], failed: [] };
}
const libraryRoot = this.settings.ensureLibraryPath();
const normalisedLibraryRoot = this.normalizeAbsolutePath(libraryRoot);
const libraryRootWithSlash = normalisedLibraryRoot.endsWith('/')
? normalisedLibraryRoot
: `${normalisedLibraryRoot}/`;
const existingFiles = this.database.listFiles();
const knownChecksums = new Set<string>();
const knownPaths = new Set<string>();
for (const file of existingFiles) {
if (typeof file.checksum === 'string' && file.checksum.length > 0) {
knownChecksums.add(file.checksum);
}
knownPaths.add(this.normalizeAbsolutePath(file.absolutePath));
}
const importFolderRelativeBase = path.join('_Imports', new Date().toISOString().slice(0, 10));
const importFolderAbsolute = path.join(libraryRoot, importFolderRelativeBase);
await fs.mkdir(importFolderAbsolute, { recursive: true });
const { files: candidateFiles, failures } = await this.collectImportCandidates(sourcePaths);
// eslint-disable-next-line no-console -- Log discovered files for debugging.
console.log(`Found ${candidateFiles.length} candidate files, ${failures.length} collection failures`);
if (failures.length > 0) {
// eslint-disable-next-line no-console -- Log collection failures for debugging.
console.log('Collection failures:', failures);
}
const imported: AudioFileSummary[] = [];
const skipped: ImportSkipEntry[] = [];
const failed: ImportFailureEntry[] = [...failures];
const usedNames = new Set<string>();
const allowedExtensions = new Set(['.wav', '.wave']);
const folderRelativeNormalised = this.normalizeRelativePath(
path.relative(libraryRoot, importFolderAbsolute)
);
const folderForJoin =
folderRelativeNormalised === '.' || folderRelativeNormalised.length === 0
? ''
: folderRelativeNormalised;
for (const candidate of candidateFiles) {
const extension = path.extname(candidate).toLowerCase();
if (!allowedExtensions.has(extension)) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: unsupported extension ${extension}`);
skipped.push({ path: candidate, reason: 'unsupported' });
continue;
}
const normalisedCandidate = this.normalizeAbsolutePath(candidate);
if (
normalisedCandidate === normalisedLibraryRoot ||
normalisedCandidate.startsWith(libraryRootWithSlash)
) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: already inside library`);
skipped.push({ path: candidate, reason: 'inside-library' });
continue;
}
if (knownPaths.has(normalisedCandidate)) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: duplicate path`);
skipped.push({ path: candidate, reason: 'duplicate' });
continue;
}
// eslint-disable-next-line no-console -- Log checksum computation for debugging.
console.log(`Computing checksum for ${candidate}...`);
const checksum = await this.computeFileChecksum(candidate);
if (!checksum) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: checksum computation failed`);
skipped.push({ path: candidate, reason: 'checksum' });
continue;
}
if (knownChecksums.has(checksum)) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: duplicate checksum ${checksum}`);
skipped.push({ path: candidate, reason: 'duplicate' });
continue;
}
// eslint-disable-next-line no-console -- Log import attempt for debugging.
console.log(`Attempting to import ${candidate} with checksum ${checksum}...`);
try {
const record = await this.copyAndRegisterImportedFile({
sourcePath: candidate,
checksum,
importFolderAbsolute,
folderForJoin,
libraryRoot,
usedNames
});
imported.push(record);
knownChecksums.add(checksum);
knownPaths.add(this.normalizeAbsolutePath(record.absolutePath));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console -- Log import failures for debugging.
console.error(`Failed to import ${candidate}:`, errorMessage);
failed.push({
path: candidate,
message: errorMessage
});
}
}
if (imported.length > 0) {
this.resetMetadataSuggestionsCache();
this.search.rebuildIndex();
}
return { imported, skipped, failed };
}
/**
* Renames a file while keeping it in the current directory.
*/
@@ -292,7 +502,7 @@ export class LibraryService {
*/
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
const record = this.database.getFileById(fileId);
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 512);
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384);
const cacheHit = this.waveformPreviewCache.get(fileId);
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
return { samples: cacheHit.samples, rms: cacheHit.rms };
@@ -302,7 +512,7 @@ export class LibraryService {
const buffer = await fs.readFile(record.absolutePath);
const wave = new WaveFile(buffer);
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
const channels = Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock];
const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
const firstChannel = channels[0];
if (!firstChannel || firstChannel.length === 0) {
@@ -510,7 +720,9 @@ export class LibraryService {
categories: updatedRecord.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updatedRecord.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
@@ -604,7 +816,9 @@ export class LibraryService {
categories: updated.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updated.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
@@ -636,6 +850,381 @@ export class LibraryService {
this.search.rebuildIndex();
}
/**
* Splits an audio file into multiple segments, writing each segment to disk and registering it in the library.
*/
public async splitFile(fileId: number, requestSegments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
if (!Array.isArray(requestSegments) || requestSegments.length === 0) {
return [];
}
const record = this.database.getFileById(fileId);
const fileBuffer = await fs.readFile(record.absolutePath);
const wave = new WaveFile(fileBuffer);
const format = (wave as WaveFile & { fmt?: { sampleRate?: number; bitsPerSample?: number } }).fmt;
const rawSamples = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
const channels = Array.isArray(rawSamples) ? rawSamples : [rawSamples];
if (channels.length === 0 || channels[0].length === 0) {
throw new Error('Unable to split file: no audio data available.');
}
const sampleRate = format?.sampleRate ?? record.sampleRate ?? null;
if (!sampleRate) {
throw new Error('Unable to split file: missing sample rate information.');
}
const sourceSampleCount = channels[0].length;
const totalDurationMs = Math.round((sourceSampleCount / sampleRate) * 1000);
const bitDepthNumeric = (() => {
const explicit = format?.bitsPerSample;
if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0) {
return explicit;
}
if (typeof record.bitDepth === 'number' && Number.isFinite(record.bitDepth) && record.bitDepth > 0) {
return record.bitDepth;
}
const parsedFromText = Number.parseInt(typeof wave.bitDepth === 'string' ? wave.bitDepth : '', 10);
return Number.isFinite(parsedFromText) && parsedFromText > 0 ? parsedFromText : null;
})();
const bitDepthText = (() => {
if (typeof wave.bitDepth === 'string' && wave.bitDepth.trim().length > 0) {
return wave.bitDepth.trim();
}
if (typeof bitDepthNumeric === 'number') {
return bitDepthNumeric.toString();
}
return '16';
})();
const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';
let originalMetadata: { author?: string | null; rating?: number; title?: string | null; copyright?: string | null } = {};
try {
const metadata = this.tagService.readMetadata(record.absolutePath);
originalMetadata = {
author: metadata.author ?? null,
rating: metadata.rating ?? undefined,
title: metadata.title ?? null,
copyright: metadata.copyright ?? null
};
} catch (error) {
console.warn('Failed to read original metadata before splitting', error);
}
const normalizedSegments = requestSegments
.map((segment) => {
const startMs = Number.isFinite(segment.startMs) ? Math.max(0, Math.min(Math.floor(segment.startMs), totalDurationMs)) : 0;
const endMs = Number.isFinite(segment.endMs) ? Math.max(0, Math.min(Math.floor(segment.endMs), totalDurationMs)) : startMs;
const safeEnd = Math.max(startMs + 1, endMs);
return {
startMs,
endMs: safeEnd,
label: segment.label?.trim() ?? undefined,
metadata: segment.metadata,
fadeInMs: Number.isFinite(segment.fadeInMs) ? Math.max(0, Math.floor(segment.fadeInMs!)) : 0,
fadeOutMs: Number.isFinite(segment.fadeOutMs) ? Math.max(0, Math.floor(segment.fadeOutMs!)) : 0
};
})
.filter((segment) => segment.endMs - segment.startMs >= 5)
.sort((a, b) => a.startMs - b.startMs);
if (normalizedSegments.length === 0) {
return [];
}
const libraryRoot = this.settings.ensureLibraryPath();
const sourceDirectory = path.dirname(record.absolutePath);
const relativeFolder = path.dirname(record.relativePath);
const folderForJoin = relativeFolder === '.' ? '' : relativeFolder;
const baseName = path.basename(record.fileName, path.extname(record.fileName));
const usedNames = new Set<string>();
let sequence = 1;
const created: AudioFileSummary[] = [];
for (const segment of normalizedSegments) {
const startSample = Math.max(0, Math.min(Math.floor((segment.startMs / 1000) * sampleRate), sourceSampleCount - 1));
const endSample = Math.max(
startSample + 1,
Math.min(Math.floor((segment.endMs / 1000) * sampleRate), sourceSampleCount)
);
if (endSample <= startSample) {
continue;
}
let segmentChannels: Float64Array[] = channels.map((channel) => channel.slice(startSample, endSample));
const fadeInMs = segment.fadeInMs ?? 0;
const fadeOutMs = segment.fadeOutMs ?? 0;
if (fadeInMs > 0 || fadeOutMs > 0) {
segmentChannels = (await this.applyFadeEnvelopeToSegment(segmentChannels, sampleRate, fadeInMs, fadeOutMs)) as Float64Array[];
}
const nextWave = new WaveFile();
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
(nextWave as WaveFile & { container?: string }).container = container;
const segmentBytes = Buffer.from(nextWave.toBuffer());
// Determine the segment name part - use label if available, otherwise use sequence
let segmentNamePart = '';
if (segment.label) {
const sanitized = this.organization.sanitizeCustomName(segment.label);
if (sanitized) {
segmentNamePart = sanitized;
}
}
let segmentFileName = '';
let segmentAbsolutePath = '';
for (let attempt = 0; attempt < 1000; attempt += 1) {
let candidateName: string;
if (segmentNamePart) {
// Use label-based name, with optional suffix for duplicates
if (attempt === 0) {
candidateName = `${baseName}_${segmentNamePart}.wav`;
} else {
candidateName = `${baseName}_${segmentNamePart}_${this.organization.formatSequenceNumber(attempt)}.wav`;
}
} else {
// Fallback to sequential numbering
const suffix = this.organization.formatSequenceNumber(sequence);
sequence += 1;
candidateName = `${baseName}_segment${suffix}.wav`;
}
if (usedNames.has(candidateName)) {
continue;
}
const candidatePath = path.join(sourceDirectory, candidateName);
const exists = await this.pathExists(candidatePath);
if (exists) {
continue;
}
segmentFileName = candidateName;
segmentAbsolutePath = candidatePath;
usedNames.add(candidateName);
break;
}
if (!segmentFileName || !segmentAbsolutePath) {
throw new Error('Failed to allocate filename for split segment.');
}
this.assertWithinLibrary(libraryRoot, segmentAbsolutePath);
await fs.writeFile(segmentAbsolutePath, segmentBytes);
const stats = await fs.stat(segmentAbsolutePath);
const checksum = createHash('md5').update(segmentBytes).digest('hex');
const relativePath = this.toLibraryRelativePath(folderForJoin, segmentFileName);
const durationMs = Math.round(((endSample - startSample) / sampleRate) * 1000);
const resolvedTagsSource = segment.metadata?.tags ?? record.tags;
const resolvedCategoriesSource = segment.metadata?.categories ?? record.categories;
const resolvedTags = Array.isArray(resolvedTagsSource) ? resolvedTagsSource.slice() : record.tags.slice();
const resolvedCategories = Array.isArray(resolvedCategoriesSource)
? resolvedCategoriesSource.slice()
: record.categories.slice();
const fileRecord = this.database.upsertFile({
absolutePath: segmentAbsolutePath,
relativePath,
fileName: segmentFileName,
displayName: path.basename(segmentFileName, '.wav'),
modifiedAt: stats.mtimeMs,
createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
size: stats.size,
durationMs,
sampleRate,
bitDepth: bitDepthNumeric,
checksum,
tags: resolvedTags,
categories: resolvedCategories,
parentFileId: record.id
});
const resolvedCustomName = segment.metadata?.customName !== undefined
? this.normaliseMetadataInput(segment.metadata.customName)
: segment.label !== undefined
? this.normaliseMetadataInput(segment.label)
: record.customName ?? null;
const updatedRecord = resolvedCustomName !== fileRecord.customName
? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
: fileRecord;
const resolvedAuthor = segment.metadata?.author !== undefined
? this.normaliseMetadataInput(segment.metadata.author)
: this.normaliseMetadataInput(originalMetadata.author ?? null);
const resolvedRating = segment.metadata?.rating !== undefined
? segment.metadata.rating ?? undefined
: originalMetadata.rating;
this.tagService.writeMetadataOnly(segmentAbsolutePath, {
tags: resolvedTags,
categories: resolvedCategories,
title: resolvedCustomName ?? updatedRecord.displayName,
author: resolvedAuthor ?? undefined,
rating: resolvedRating,
copyright: originalMetadata.copyright ?? null,
parentId: record.id
});
if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
this.updateMetadataSuggestionsCache(resolvedAuthor);
}
this.waveformPreviewCache.delete(updatedRecord.id);
created.push(updatedRecord);
}
this.waveformPreviewCache.delete(record.id);
this.resetMetadataSuggestionsCache();
this.search.rebuildIndex();
return created;
}
/**
* Applies the configured fade envelope to the provided channel data. Tries to render using
* `standardized-audio-context` for consistency with the editor preview and falls back to
* a manual smooth-step implementation if the dependency is unavailable at runtime.
*/
private async applyFadeEnvelopeToSegment(
source: Float64Array[],
sampleRate: number,
fadeInMs: number,
fadeOutMs: number
): Promise<Float64Array[]> {
if (source.length === 0 || source[0]?.length === 0) {
return source.map((channel) => channel.slice());
}
const totalSamples = source[0].length;
const fadeInSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeInMs / 1000) * sampleRate)));
const fadeOutSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeOutMs / 1000) * sampleRate)));
if (fadeInSamples === 0 && fadeOutSamples === 0) {
return source.map((channel) => channel.slice());
}
const applyManual = (): Float64Array[] => {
return source.map((channel) => {
const copy = channel.slice();
const sampleCount = copy.length;
const fadeInDenominator = Math.max(1, fadeInSamples - 1);
const fadeOutDenominator = Math.max(1, fadeOutSamples - 1);
for (let index = 0; index < sampleCount; index += 1) {
let gain = 1;
if (fadeInSamples > 0 && index < fadeInSamples) {
const t = fadeInDenominator > 0 ? index / fadeInDenominator : 0;
const eased = this.smoothStep(t);
gain = Math.min(gain, eased);
}
if (fadeOutSamples > 0) {
const fromEnd = sampleCount - 1 - index;
if (fromEnd < fadeOutSamples) {
const t = fadeOutDenominator > 0 ? fromEnd / fadeOutDenominator : 0;
const eased = this.smoothStep(t);
gain = Math.min(gain, eased);
}
}
copy[index] = copy[index] * gain;
}
return copy;
});
};
try {
let OfflineContextCtor = this.offlineAudioContextCtor;
if (!OfflineContextCtor) {
const audioModule = (await import('standardized-audio-context')) as {
OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any;
default?: { OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any };
};
const resolvedCtor = audioModule.OfflineAudioContext ?? audioModule.default?.OfflineAudioContext ?? null;
if (typeof resolvedCtor !== 'function') {
return applyManual();
}
this.offlineAudioContextCtor = resolvedCtor;
OfflineContextCtor = resolvedCtor;
}
const channelCount = source.length;
const offlineContext = new OfflineContextCtor(channelCount, totalSamples, sampleRate);
const buffer = offlineContext.createBuffer(channelCount, totalSamples, sampleRate);
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const channelData = buffer.getChannelData(channelIndex);
const sourceChannel = source[channelIndex];
for (let sampleIndex = 0; sampleIndex < totalSamples; sampleIndex += 1) {
channelData[sampleIndex] = sourceChannel?.[sampleIndex] ?? 0;
}
}
const bufferSource = offlineContext.createBufferSource();
bufferSource.buffer = buffer;
const gainNode = offlineContext.createGain();
bufferSource.connect(gainNode);
gainNode.connect(offlineContext.destination);
const totalDurationSeconds = totalSamples / sampleRate;
gainNode.gain.setValueAtTime(1, 0);
if (fadeInSamples > 0) {
const fadeInCurve = this.generateFadeCurve(Math.max(fadeInSamples, 2), 'in');
gainNode.gain.setValueAtTime(fadeInCurve[0], 0);
gainNode.gain.setValueCurveAtTime(fadeInCurve, 0, fadeInSamples / sampleRate);
gainNode.gain.setValueAtTime(1, fadeInSamples / sampleRate);
}
if (fadeOutSamples > 0) {
const fadeOutCurve = this.generateFadeCurve(Math.max(fadeOutSamples, 2), 'out');
const fadeOutStart = Math.max(0, totalDurationSeconds - fadeOutSamples / sampleRate);
gainNode.gain.setValueAtTime(1, fadeOutStart);
gainNode.gain.setValueCurveAtTime(fadeOutCurve, fadeOutStart, fadeOutSamples / sampleRate);
gainNode.gain.setValueAtTime(fadeOutCurve[fadeOutCurve.length - 1], totalDurationSeconds);
}
bufferSource.start(0);
const renderedBuffer = await offlineContext.startRendering();
const processed: Float64Array[] = [];
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const renderedChannel = renderedBuffer.getChannelData(channelIndex);
const clone = new Float64Array(renderedChannel.length);
clone.set(renderedChannel);
processed.push(clone);
}
return processed;
} catch (error) {
console.warn('Falling back to manual fade processing', error);
return applyManual();
}
}
/**
* Generates a smooth fade curve using a cubic smooth-step easing function.
*/
private generateFadeCurve(sampleCount: number, direction: 'in' | 'out'): Float32Array {
const totalSamples = Math.max(sampleCount, 2);
const curve = new Float32Array(totalSamples);
const lastIndex = totalSamples - 1;
for (let index = 0; index < totalSamples; index += 1) {
const t = lastIndex === 0 ? 1 : index / lastIndex;
const eased = this.smoothStep(Math.min(Math.max(t, 0), 1));
curve[index] = direction === 'in' ? eased : 1 - eased;
}
if (direction === 'in') {
curve[0] = 0;
curve[lastIndex] = 1;
} else {
curve[0] = 1;
curve[lastIndex] = 0;
}
return curve;
}
/**
* Computes the cubic smooth-step easing value for the provided normalised progress.
*/
private smoothStep(t: number): number {
const clamped = Math.min(Math.max(t, 0), 1);
return clamped * clamped * (3 - 2 * clamped);
}
/**
* Returns the previously parsed UCS category catalog.
*/
@@ -679,7 +1268,9 @@ export class LibraryService {
categories: record.categories,
title: record.customName ?? existing.title,
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
rating: metadata.rating !== undefined ? metadata.rating : existing.rating
rating: metadata.rating !== undefined ? metadata.rating : existing.rating,
copyright: existing.copyright ?? null,
parentId: record.parentFileId ?? null
};
// Write merged metadata to the WAV file
@@ -904,6 +1495,165 @@ export class LibraryService {
return value.replace(/\\/g, '/');
}
/**
* Gathers candidate audio files from the provided sources, handling both folders and individual files.
*/
private async collectImportCandidates(sourcePaths: string[]): Promise<{
files: string[];
failures: ImportFailureEntry[];
}> {
const discovered = new Set<string>();
const failures: ImportFailureEntry[] = [];
const uniqueSources = Array.from(
new Set((sourcePaths ?? []).map((entry) => entry?.trim()).filter((entry): entry is string => Boolean(entry)))
);
for (const rawSource of uniqueSources) {
const absoluteSource = path.resolve(rawSource);
try {
const stats = await fs.stat(absoluteSource);
if (stats.isDirectory()) {
try {
const matches = await fg(['**/*.wav', '**/*.wave'], {
cwd: absoluteSource,
absolute: true,
onlyFiles: true,
suppressErrors: true,
caseSensitiveMatch: false
});
for (const match of matches) {
discovered.add(path.resolve(match));
}
} catch (error) {
failures.push({
path: absoluteSource,
message: error instanceof Error ? error.message : String(error)
});
}
} else if (stats.isFile()) {
discovered.add(absoluteSource);
} else {
failures.push({ path: absoluteSource, message: 'Unsupported file system entry.' });
}
} catch (error) {
failures.push({
path: absoluteSource,
message: error instanceof Error ? error.message : String(error)
});
}
}
const files = Array.from(discovered).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
return { files, failures };
}
/**
* Copies a single source file into the library and registers it in the database, returning the stored record.
*/
private async copyAndRegisterImportedFile(options: {
sourcePath: string;
checksum: string;
importFolderAbsolute: string;
folderForJoin: string;
libraryRoot: string;
usedNames: Set<string>;
}): Promise<AudioFileSummary> {
const { sourcePath, checksum, importFolderAbsolute, folderForJoin, libraryRoot, usedNames } = options;
const extension = path.extname(sourcePath);
const baseName = path.basename(sourcePath, extension);
let sanitizedBase = this.organization.sanitizeCustomName(baseName);
if (!sanitizedBase) {
sanitizedBase = 'Imported';
}
let attempt = 0;
let candidateName: string = '';
let candidateAbsolutePath: string = '';
while (true) {
if (attempt === 0) {
candidateName = this.normaliseFileName(`${sanitizedBase}.wav`);
} else {
const suffix = this.organization.formatSequenceNumber(attempt);
candidateName = this.normaliseFileName(`${sanitizedBase}_${suffix}.wav`);
}
if (!usedNames.has(candidateName)) {
candidateAbsolutePath = path.join(importFolderAbsolute, candidateName);
const exists = await this.pathExists(candidateAbsolutePath);
if (!exists) {
break;
}
}
attempt += 1;
if (attempt > 9999) {
throw new Error('Unable to allocate a unique filename for the imported file.');
}
}
usedNames.add(candidateName);
this.assertWithinLibrary(libraryRoot, candidateAbsolutePath);
await fs.copyFile(sourcePath, candidateAbsolutePath);
try {
const stats = await fs.stat(candidateAbsolutePath);
const metadata = await this.extractAudioMetadata(candidateAbsolutePath);
const relativePath = this.toLibraryRelativePath(folderForJoin, candidateName);
const record = this.database.upsertFile({
absolutePath: candidateAbsolutePath,
relativePath,
fileName: candidateName,
displayName: path.basename(candidateName, path.extname(candidateName)),
modifiedAt: stats.mtimeMs,
createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
size: stats.size,
durationMs: metadata.durationMs,
sampleRate: metadata.sampleRate,
bitDepth: metadata.bitDepth,
checksum,
tags: metadata.tags,
categories: metadata.categories
});
try {
const embedded = this.tagService.readMetadata(candidateAbsolutePath);
this.updateMetadataSuggestionsCache(embedded.author ?? null);
} catch (metadataError) {
// eslint-disable-next-line no-console -- Import should continue even if metadata read fails.
console.warn('Failed to read metadata from imported file', metadataError);
}
return record;
} catch (error) {
await fs.rm(candidateAbsolutePath, { force: true }).catch(() => undefined);
throw error;
}
}
/**
* Resolves and caches the music-metadata parseFile implementation.
*/
private async resolveMusicMetadataParser(): Promise<MusicMetadataParser> {
if (this.musicMetadataParseFile) {
return this.musicMetadataParseFile;
}
const namespace = (await import('music-metadata')) as MusicMetadataNamespace;
const candidate = typeof namespace.parseFile === 'function'
? namespace.parseFile
: namespace.default && typeof namespace.default.parseFile === 'function'
? namespace.default.parseFile
: null;
if (!candidate) {
throw new Error('music-metadata parseFile not found');
}
this.musicMetadataParseFile = candidate;
return candidate;
}
/**
* Extracts metadata from the WAV container, falling back to defaults when parsing fails.
*/
@@ -915,22 +1665,8 @@ export class LibraryService {
categories: string[];
}> {
try {
const mmImport = await import('music-metadata');
const mm = (mmImport as { default?: unknown }).default ?? mmImport;
const loadMusicMetadata = typeof mm === 'object' && mm !== null && 'loadMusicMetadata' in mm
? (mm as { loadMusicMetadata: () => Promise<{ parseFile: (path: string, opts?: { duration?: boolean }) => Promise<{
format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null };
common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown };
}> }> }).loadMusicMetadata
: null;
if (!loadMusicMetadata) {
throw new Error('music-metadata loadMusicMetadata not found');
}
const musicMetadata = await loadMusicMetadata();
const metadata = await musicMetadata.parseFile(filePath, { duration: true });
const parseFile = await this.resolveMusicMetadataParser();
const metadata = await parseFile(filePath, { duration: true });
const infoTags = this.tagService.readInfoTags(filePath);
const splitInfoValues = (input: string | undefined): string[] =>
input

View File

@@ -1,4 +1,4 @@
import Fuse from 'fuse.js';
import Fuse, { type FuseResult, type IFuseOptions } from 'fuse.js';
import { AudioFileSummary } from '../../shared/models';
import { DatabaseService } from './DatabaseService';
import { TagService } from './TagService';
@@ -76,7 +76,7 @@ export class SearchService {
return fuseInstance
.search(remainingQuery)
.map((result: Fuse.FuseResult<AudioFileSummary>) => result.item)
.map((result: FuseResult<AudioFileSummary>) => result.item)
.filter((file) => (filters.length === 0 ? true : this.matchesAdvancedFilters(file, filters)));
}
@@ -103,7 +103,7 @@ export class SearchService {
/**
* Provides the standard Fuse configuration used by the service.
*/
private createFuseOptions(): Fuse.IFuseOptions<AudioFileSummary> {
private createFuseOptions(): IFuseOptions<AudioFileSummary> {
return {
includeScore: true,
threshold: 0.35,
@@ -114,7 +114,7 @@ export class SearchService {
{ name: 'tags', weight: 0.2 },
{ name: 'categories', weight: 0.1 }
]
} satisfies Fuse.IFuseOptions<AudioFileSummary>;
} satisfies IFuseOptions<AudioFileSummary>;
}
/**

View File

@@ -10,15 +10,25 @@ 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.
* Applies category updates (and optional tag overrides) then embeds metadata into the WAV container.
* Preserves existing author, title, and rating fields.
*/
public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
const normalisedTags = this.normaliseValues(tags);
public applyTagging(fileId: number, tags: string[] | undefined, categories: string[]): AudioFileSummary {
const normalisedCategories = this.normaliseValues(categories);
const normalisedTags = Array.isArray(tags) ? this.normaliseValues(tags) : undefined;
const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories);
// Read existing metadata to preserve author, title, and rating
const existing = this.readMetadata(updated.absolutePath);
this.writeWaveMetadata(updated.absolutePath, {
tags: normalisedTags,
categories: normalisedCategories
tags: normalisedTags ?? updated.tags,
categories: normalisedCategories,
title: existing.title,
author: existing.author,
rating: existing.rating,
copyright: existing.copyright,
parentId: existing.parentId ?? updated.parentFileId ?? null
});
return updated;
}
@@ -44,21 +54,56 @@ export class TagService {
author?: string;
title?: string;
rating?: number;
copyright?: string;
parentId?: number;
} {
try {
const buffer = fs.readFileSync(filePath);
const wave = new WaveFile(buffer);
const tags = this.extractInfoTagMap(wave);
// Read from individual fields
const title = tags.INAM?.trim() || undefined;
const author = tags.IART?.trim() || undefined;
const copyright = tags.ICOP?.trim() || undefined;
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);
}
// Try to read parentId from JSON comment
let parentId: number | undefined;
const comment = tags.ICMT?.trim();
if (comment) {
try {
const parsed = JSON.parse(comment);
if (parsed && typeof parsed.parentId === 'number') {
parentId = parsed.parentId;
}
} catch {
// Not JSON, ignore
}
}
// Fallback to IPAR field if no JSON parentId
if (parentId === undefined) {
const parentRaw = tags.IPAR?.trim();
if (parentRaw && parentRaw.length > 0) {
const parsedNum = Number.parseInt(parentRaw, 10);
if (Number.isFinite(parsedNum)) {
parentId = parsedNum;
}
}
}
return {
author: tags.IART?.trim() || undefined,
title: tags.INAM?.trim() || undefined,
rating
author,
title,
rating,
copyright,
parentId
};
} catch (error) {
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
@@ -69,6 +114,7 @@ export class TagService {
/**
* Writes tags and categories to an organized file as embedded metadata.
* All metadata fields are required to prevent accidental clearing.
*/
public writeMetadataOnly(
filePath: string,
@@ -78,6 +124,8 @@ export class TagService {
title?: string | null;
author?: string | null;
rating?: number;
copyright?: string | null;
parentId?: number | null;
}
): void {
this.writeWaveMetadata(filePath, metadata);
@@ -85,6 +133,7 @@ export class TagService {
/**
* Writes a simple INFO chunk with the provided metadata. Failures are swallowed so DB state remains authoritative.
* All metadata fields must be explicitly provided to prevent accidental data loss.
*/
private writeWaveMetadata(
filePath: string,
@@ -94,6 +143,8 @@ export class TagService {
title?: string | null;
author?: string | null;
rating?: number;
copyright?: string | null;
parentId?: number | null;
}
): void {
try {
@@ -104,22 +155,41 @@ export class TagService {
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 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 primaryCategory = categoryValuesList.length > 0 ? categoryValuesList[0] : null;
const trimmedTitle = metadata.title?.toString().trim();
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
const trimmedAuthor = metadata.author?.toString().trim();
const effectiveAuthor = trimmedAuthor && trimmedAuthor.length > 0 ? trimmedAuthor : null;
const trimmedCopyright = metadata.copyright?.toString().trim();
const effectiveCopyright = trimmedCopyright && trimmedCopyright.length > 0 ? trimmedCopyright : null;
const parentText = metadata.parentId !== undefined && metadata.parentId !== null
? String(metadata.parentId)
: null;
const commentPayload = {
parentId: metadata.parentId ?? null
} satisfies Record<string, unknown>;
const commentText = JSON.stringify(commentPayload);
const tagValues: Record<string, string | null> = {
IKEY: tagText,
ICMT: tagText,
ICMT: commentText,
ISBJ: categoryText,
ISUB: primaryCategory,
IGNR: null,
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
INAM: effectiveTitle,
IART: effectiveAuthor,
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
ICOP: effectiveCopyright,
IPAR: parentText,
ISFT: 'AudioSort'
};
@@ -129,11 +199,13 @@ export class TagService {
fs.writeFileSync(filePath, updatedBuffer);
console.log(`Wrote metadata to ${filePath}:`, {
tags: metadata.tags.join(', '),
comment: commentText,
categories: metadata.categories.join(', '),
title: metadata.title,
author: metadata.author,
rating: metadata.rating
title: effectiveTitle,
author: effectiveAuthor,
copyright: effectiveCopyright,
rating: metadata.rating,
parentId: metadata.parentId ?? null
});
} catch (error) {
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.

View File

@@ -0,0 +1,10 @@
declare module 'standardized-audio-context' {
export class OfflineAudioContext {
constructor(channelCount: number, length: number, sampleRate: number);
createBuffer(channelCount: number, length: number, sampleRate: number): any;
createBufferSource(): any;
createGain(): any;
startRendering(): Promise<any>;
readonly destination: any;
}
}

View File

@@ -5,7 +5,9 @@ import type {
AudioBufferPayload,
AudioFileSummary,
CategoryRecord,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
} from '../shared/models';
@@ -25,9 +27,22 @@ const api: RendererApi = {
async rescanLibrary(): Promise<LibraryScanSummary> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryScan);
},
async selectImportFolder(): Promise<string | null> {
return ipcRenderer.invoke(IPC_CHANNELS.dialogSelectImportFolder);
},
async listSystemDrives(): Promise<string[]> {
return ipcRenderer.invoke(IPC_CHANNELS.systemListDrives);
},
async listAudioFiles(): Promise<AudioFileSummary[]> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryList);
},
async importExternalSources(paths: string[]): Promise<LibraryImportResult> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryImport, paths);
},
/** Retrieves a single audio file summary by id, returning null when the record no longer exists. */
async getAudioFileById(fileId: number): Promise<AudioFileSummary | null> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryGetById, fileId);
},
async listDuplicates(): Promise<{ checksum: string; files: AudioFileSummary[] }[]> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryDuplicates);
},
@@ -37,7 +52,10 @@ const api: RendererApi = {
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> {
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> {
@@ -49,6 +67,9 @@ const api: RendererApi = {
async deleteFiles(fileIds: number[]): Promise<void> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryDelete, fileIds);
},
async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
return ipcRenderer.invoke(IPC_CHANNELS.librarySplit, fileId, segments);
},
async getAudioBuffer(fileId: number): Promise<AudioBufferPayload> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryBuffer, fileId);
},
@@ -70,11 +91,14 @@ const api: RendererApi = {
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> {
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();
onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void {
const listener = (_event: Electron.IpcRendererEvent, payload?: unknown) => callback(payload);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, type JSX } from 'react';
import FileList from './components/FileList';
import FileDetailPanel from './components/FileDetailPanel';
import MultiFileEditor from './components/MultiFileEditor';
@@ -6,10 +6,13 @@ import CategorySidebar from './components/CategorySidebar';
import AudioPlayer from './components/AudioPlayer';
import SettingsDialog from './components/SettingsDialog';
import DuplicateComparisonDialog from './components/DuplicateComparisonDialog';
import EditModePanel from './components/edit/EditModePanel';
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore';
import type { AudioFileSummary } from '../../shared/models';
import type { AudioFileSummary, LibraryImportResult } from '../../shared/models';
type RightPanelTab = 'listen' | 'edit';
/**
* Root renderer component orchestrating layout and interactions.
@@ -21,14 +24,19 @@ function App(): JSX.Element {
const [duplicateGroups, setDuplicateGroups] = useState<{ checksum: string; files: AudioFileSummary[] }[] | null>(null);
const [showStatusMessage, setShowStatusMessage] = useState(false);
const [statusFadingOut, setStatusFadingOut] = useState(false);
const [activeTab, setActiveTab] = useState<RightPanelTab>('listen');
const [importMessage, setImportMessage] = useState<string | null>(null);
const statusMessage = useMemo(() => {
if (importMessage) {
return importMessage;
}
if (!library.lastScan) {
return null;
}
const { added, updated, removed } = library.lastScan;
return `Scan complete: +${added} updated ${updated} removed ${removed}`;
}, [library.lastScan]);
}, [importMessage, library.lastScan]);
useEffect(() => {
if (statusMessage) {
@@ -40,6 +48,7 @@ function App(): JSX.Element {
const hideTimer = setTimeout(() => {
setShowStatusMessage(false);
setStatusFadingOut(false);
setImportMessage((current) => (current === statusMessage ? null : current));
}, 5000);
return () => {
clearTimeout(fadeTimer);
@@ -48,23 +57,52 @@ function App(): JSX.Element {
}
}, [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 parentFileFromStore = useMemo(() => {
const parentId = selectedFile?.parentFileId ?? null;
if (parentId === null) {
return null;
}
return library.files.find((file) => file.id === parentId) ?? null;
}, [library.files, selectedFile?.parentFileId]);
const [resolvedParentFile, setResolvedParentFile] = useState<AudioFileSummary | null>(parentFileFromStore);
useEffect(() => {
const parentId = selectedFile?.parentFileId ?? null;
if (parentId === null) {
setResolvedParentFile(null);
return;
}
if (parentFileFromStore) {
setResolvedParentFile(parentFileFromStore);
return;
}
let cancelled = false;
(async () => {
try {
const fetched = await window.api.getAudioFileById(parentId);
if (!cancelled) {
setResolvedParentFile(fetched ?? null);
}
} catch (error) {
console.warn('Unable to resolve parent file details', { parentId, error });
if (!cancelled) {
setResolvedParentFile(null);
}
}
})();
return () => {
cancelled = true;
};
}, [parentFileFromStore, selectedFile?.parentFileId]);
const selectedFiles = useMemo(
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
[library.files, library.selectedFileIds]
@@ -110,14 +148,82 @@ function App(): JSX.Element {
void libraryStore.search(value);
};
const handleRescan = async () => {
const handleRescan = useCallback(async () => {
await libraryStore.rescan();
};
}, []);
const handleFindDuplicates = async () => {
const handleFindDuplicates = useCallback(async () => {
const duplicates = await window.api.listDuplicates();
setDuplicateGroups(duplicates);
}, []);
const summariseImportResult = useCallback((result: LibraryImportResult): string => {
const parts: string[] = [`${result.imported.length} added`];
if (result.skipped.length > 0) {
parts.push(`${result.skipped.length} skipped`);
}
if (result.failed.length > 0) {
parts.push(`${result.failed.length} failed`);
}
return `Import complete: ${parts.join(', ')}`;
}, []);
const notifyImportSuccess = useCallback((result: LibraryImportResult) => {
setImportMessage(summariseImportResult(result));
}, [summariseImportResult]);
const notifyImportFailure = useCallback(() => {
setImportMessage('Import failed. Check logs for details.');
}, []);
const handleImportFromFolder = useCallback(async () => {
try {
const result = await libraryStore.importFromFolder();
if (!result) {
return;
}
notifyImportSuccess(result);
} catch (error) {
console.error('Import failed', error);
notifyImportFailure();
}
}, [notifyImportFailure, notifyImportSuccess]);
const handleImportFromDrive = useCallback(async (drive: string) => {
try {
const result = await libraryStore.importFromDrive(drive);
notifyImportSuccess(result);
} catch (error) {
console.error('Import failed', error);
notifyImportFailure();
}
}, [notifyImportFailure, notifyImportSuccess]);
useEffect(() => {
const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true));
const cleanup2 = window.api.onMenuAction('rescan-library', () => {
void handleRescan();
});
const cleanup3 = window.api.onMenuAction('find-duplicates', () => {
void handleFindDuplicates();
});
const cleanup4 = window.api.onMenuAction('import-from-folder', () => {
void handleImportFromFolder();
});
const cleanup5 = window.api.onMenuAction('import-from-drive', (drive) => {
if (typeof drive === 'string') {
void handleImportFromDrive(drive);
}
});
return () => {
cleanup1();
cleanup2();
cleanup3();
cleanup4();
cleanup5();
};
}, [handleFindDuplicates, handleImportFromFolder, handleImportFromDrive, handleRescan]);
const handleKeepDuplicate = async (fileIdToKeep: number, fileIdsToDelete: number[]) => {
await window.api.deleteFiles(fileIdsToDelete);
@@ -132,10 +238,6 @@ function App(): JSX.Element {
libraryStore.selectFile(fileId, options);
};
const handleSelectAllFiles = () => {
libraryStore.selectAllVisibleFiles();
};
const handlePlayFile = (file: AudioFileSummary) => {
loadPlayerFile(file, true);
};
@@ -158,11 +260,19 @@ function App(): JSX.Element {
await libraryStore.moveFile(selectedFile.id, targetDirectory);
};
const handleOrganize = async (metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => {
const handleOrganize = async (metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => {
if (!selectedFile) {
return;
}
await libraryStore.organizeFile(selectedFile.id, metadata);
// Scroll the file into view after organizing (filename may have changed)
setTimeout(() => {
const element = document.querySelector(`[data-file-id="${selectedFile.id}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 5000);
};
const handleUpdateCustomName = async (customName: string | null) => {
@@ -172,26 +282,31 @@ function App(): JSX.Element {
await libraryStore.updateCustomName(selectedFile.id, customName);
};
const handleTagUpdate = async (data: { tags: string[]; categories: string[] }) => {
const handleTagUpdate = async (categories: string[]) => {
if (!selectedFile) {
return;
}
await libraryStore.updateTagging({ fileId: selectedFile.id, ...data });
await libraryStore.updateTagging({ fileId: selectedFile.id, categories });
};
const handleMultiFileTagUpdate = async (fileId: number, data: { tags: string[]; categories: string[] }) => {
await libraryStore.updateTagging({ fileId, ...data });
const handleOpenParent = (parentId: number) => {
void libraryStore.focusOnFile(parentId);
setActiveTab('listen');
};
const handleMultiFileTagUpdate = async (fileId: number, categories: string[]) => {
await libraryStore.updateTagging({ fileId, categories });
};
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 }) => {
const handleMultiFileOrganize = async (fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => {
await libraryStore.organizeFile(fileId, metadata);
};
const handleMultiFileUpdateMetadata = async (fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) => {
const handleMultiFileUpdateMetadata = async (fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) => {
await libraryStore.updateFileMetadata(fileId, metadata);
};
@@ -206,7 +321,6 @@ function App(): JSX.Element {
const newCategories = [...existingCategories, categoryId];
await libraryStore.updateTagging({
fileId,
tags: file.tags,
categories: newCategories
});
}
@@ -217,6 +331,17 @@ function App(): JSX.Element {
setSettingsOpen(false);
};
const handleEditModeClose = () => {
setActiveTab('listen');
};
const handleEditModeSplitComplete = (created: AudioFileSummary[]) => {
setActiveTab('listen');
if (created.length > 0) {
libraryStore.selectFile(created[0].id);
}
};
return (
<div className="app-root">
{showStatusMessage && statusMessage && (
@@ -229,6 +354,7 @@ function App(): JSX.Element {
categories={library.categories}
files={library.files}
activeFilter={library.categoryFilter}
justSplitIds={library.justSplitFileIds}
onSelect={handleCategorySelect}
onDropFiles={handleDropFilesToCategory}
/>
@@ -240,7 +366,6 @@ function App(): JSX.Element {
onPlay={handlePlayFile}
searchValue={library.searchQuery}
onSearchChange={handleSearch}
onSelectAll={handleSelectAllFiles}
/>
<div className="app-details">
{isMultiSelect ? (
@@ -254,19 +379,49 @@ function App(): JSX.Element {
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
/>
) : (
<>
<div className="detail-tabs">
<button
type="button"
className={activeTab === 'listen' ? 'detail-tab detail-tab--active' : 'detail-tab'}
onClick={() => setActiveTab('listen')}
>
Listen
</button>
<button
type="button"
className={activeTab === 'edit' ? 'detail-tab detail-tab--active' : 'detail-tab'}
onClick={() => setActiveTab('edit')}
disabled={!selectedFile}
>
Edit
</button>
</div>
{activeTab === 'listen' ? (
<>
<FileDetailPanel
file={selectedFile}
parentFile={resolvedParentFile}
categories={library.categories}
onRename={handleRename}
onMove={handleMove}
onOrganize={handleOrganize}
onUpdateTags={handleTagUpdate}
onUpdateCustomName={handleUpdateCustomName}
onOpenParent={handleOpenParent}
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
/>
<AudioPlayer snapshot={player} />
</>
) : selectedFile ? (
<EditModePanel
file={selectedFile}
categories={library.categories}
onClose={handleEditModeClose}
onSplitComplete={handleEditModeSplitComplete}
/>
) : null}
</>
)}
</div>
</main>

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
import { CATEGORY_FILTER_UNTAGGED, type CategoryFilterValue } from '../stores/LibraryStore';
import { CATEGORY_FILTER_JUST_SPLIT, CATEGORY_FILTER_UNTAGGED, type CategoryFilterValue } from '../stores/LibraryStore';
import {
buildCategorySwatch,
createCategoryStyleVars,
@@ -11,6 +11,8 @@ export interface CategorySidebarProps {
categories: CategoryRecord[];
files: AudioFileSummary[];
activeFilter: CategoryFilterValue;
/** Identifiers of files created during the most recent split action. */
justSplitIds: number[];
onSelect(filter: CategoryFilterValue): void;
onDropFiles?(fileIds: number[], categoryId: string): void;
}
@@ -37,7 +39,7 @@ function deriveCounts(files: AudioFileSummary[]): {
};
}
export function CategorySidebar({ categories, files, activeFilter, onSelect, onDropFiles }: CategorySidebarProps): JSX.Element {
export function CategorySidebar({ categories, files, activeFilter, justSplitIds, onSelect, onDropFiles }: CategorySidebarProps): JSX.Element {
const { total, untagged, categoryCounts } = deriveCounts(files);
const swatchMap = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildCategorySwatch>>();
@@ -103,6 +105,17 @@ export function CategorySidebar({ categories, files, activeFilter, onSelect, onD
<span className="category-sidebar__label">TO TAG</span>
<span className="category-sidebar__count">{untagged}</span>
</button>
{justSplitIds.length > 0 ? (
<button
type="button"
className="category-sidebar__item"
data-active={activeFilter === CATEGORY_FILTER_JUST_SPLIT}
onClick={() => handleSelect(CATEGORY_FILTER_JUST_SPLIT)}
>
<span className="category-sidebar__label">JUST SPLIT</span>
<span className="category-sidebar__count">{justSplitIds.length}</span>
</button>
) : null}
</div>
{sortedGroups.map(([groupName, records]) => {
const sortedRecords = records

View File

@@ -0,0 +1,78 @@
import type { JSX } from 'react';
export interface DrivePickerDialogProps {
drives: string[];
loading: boolean;
importing: boolean;
error: string | null;
onRefresh(): void;
onSelect(drivePath: string): void;
onClose(): void;
}
/**
* Modal dialog that lists available system drives for import selection.
*/
export function DrivePickerDialog({
drives,
loading,
importing,
error,
onRefresh,
onSelect,
onClose
}: DrivePickerDialogProps): JSX.Element {
const handleBackdropClick = () => {
if (!importing && !loading) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleBackdropClick}>
<div className="modal-content drive-picker" onClick={(event) => event.stopPropagation()}>
<header className="drive-picker__header">
<h2>Import From Drive</h2>
<p>Select a drive to copy new WAV files into your library.</p>
</header>
{error ? <div className="drive-picker__error">{error}</div> : null}
<div className="drive-picker__body">
{loading ? (
<p className="drive-picker__status">Loading available drives</p>
) : drives.length === 0 ? (
<p className="drive-picker__status">No drives were detected.</p>
) : (
<ul className="drive-picker__list">
{drives.map((drive) => (
<li key={drive}>
<button
type="button"
className="drive-picker__drive"
onClick={() => onSelect(drive)}
disabled={importing}
>
<span className="drive-picker__drive-label">{drive}</span>
<span className="drive-picker__drive-hint">Press Enter to import from this drive</span>
</button>
</li>
))}
</ul>
)}
</div>
<footer className="drive-picker__footer">
<button type="button" className="ghost-button" onClick={onRefresh} disabled={loading || importing}>
Refresh Drives
</button>
<button type="button" className="ghost-button" onClick={onClose} disabled={importing}>
Close
</button>
</footer>
</div>
</div>
);
}
export default DrivePickerDialog;

View File

@@ -2,25 +2,36 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
import { TagEditor } from './TagEditor';
/**
* Normalizes path separators to backslashes for consistent display on Windows.
*/
function normalizePathDisplay(path: string): string {
return path.replace(/\//g, '\\');
}
/**
* Props for FileDetailPanel component.
*/
export interface FileDetailPanelProps {
file: AudioFileSummary | null;
/** Optional summary of the parent file when this entry was generated from another file. */
parentFile: AudioFileSummary | null;
categories: CategoryRecord[];
onRename(newName: string): Promise<void>;
onMove(targetRelativeDirectory: string): Promise<void>;
/** Organizes a file with optional metadata fields (customName, author, rating 1-5). */
onOrganize(metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
/** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */
onOrganize(metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise<void>;
onUpdateTags(categories: string[]): Promise<void>;
onUpdateCustomName(customName: string | null): Promise<void>;
/** Invoked when the user requests to open the parent file. */
onOpenParent?(parentId: number): 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 {
export function FileDetailPanel({ file, parentFile, categories, onRename, onMove, onOrganize, onUpdateTags, onUpdateCustomName, onOpenParent, metadataSuggestionsVersion }: FileDetailPanelProps): JSX.Element {
const [isEditingCustomName, setIsEditingCustomName] = useState(false);
const [moveDraft, setMoveDraft] = useState('');
const [customNameDraft, setCustomNameDraft] = useState('');
@@ -29,8 +40,9 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
const [busy, setBusy] = useState(false);
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
const initialMetadataRef = useRef<{ author: string; rating: number; customName: string }>({
const initialMetadataRef = useRef<{ author: string; copyright: string; rating: number; customName: string }>({
author: '',
copyright: '',
rating: 0,
customName: ''
});
@@ -93,7 +105,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
setAuthorDraft('');
setRatingDraft(0);
setIsEditingCustomName(false);
initialMetadataRef.current = { author: '', rating: 0, customName: '' };
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: '' };
return;
}
@@ -101,7 +113,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
setCustomNameDraft(file.customName ?? '');
setIsEditingCustomName(false);
const baseCustomName = (file.customName ?? '').trim();
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
void (async () => {
try {
@@ -117,6 +129,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
setRatingDraft(ratingValue);
initialMetadataRef.current = {
author: authorValue,
copyright: '',
rating: ratingValue,
customName: customNameValue
};
@@ -127,7 +140,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
console.error('Failed to read file metadata:', error);
setAuthorDraft('');
setRatingDraft(0);
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
}
})();
}, [appendSuggestion, file?.id]);
@@ -190,7 +203,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
}
};
const triggerOrganize = async (overrides?: { author?: string | null; rating?: number; customName?: string | null }) => {
const triggerOrganize = async (overrides?: { author?: string; rating?: number; customName?: string | null }, force = false) => {
if (!file) {
return;
}
@@ -200,18 +213,21 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
const nextCustomNameRaw =
overrides?.customName !== undefined ? overrides.customName ?? '' : customNameDraft;
const trimmedAuthor = (nextAuthorRaw ?? '').trim();
const trimmedAuthor = nextAuthorRaw.trim();
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
const comparisonState = {
author: trimmedAuthor,
copyright: '',
rating: nextRatingValue,
customName: trimmedCustomName
};
const previous = initialMetadataRef.current;
if (
!force &&
previous &&
previous.author === comparisonState.author &&
previous.copyright === comparisonState.copyright &&
previous.rating === comparisonState.rating &&
previous.customName === comparisonState.customName
) {
@@ -220,18 +236,17 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
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,
author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined,
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
});
} else {
// Has categories - organize the file
await onOrganize({
customName: trimmedCustomName.length > 0 ? trimmedCustomName : null,
author: authorPayload,
customName: trimmedCustomName.length > 0 ? trimmedCustomName : undefined,
author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined,
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
});
}
@@ -247,10 +262,12 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
}
};
const handleTagSave = async (data: { tags: string[]; categories: string[] }) => {
const handleCategorySave = async (selectedCategories: string[]) => {
setBusy(true);
try {
await onUpdateTags(data);
await onUpdateTags(selectedCategories);
} catch (error) {
console.error('Failed to update categories', error);
} finally {
setBusy(false);
}
@@ -265,6 +282,13 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
}
};
const handleOpenParent = () => {
if (!file || file.parentFileId === null || !onOpenParent) {
return;
}
onOpenParent(file.parentFileId);
};
const toggleTagSection = () => {
setIsTagSectionExpanded((value) => !value);
};
@@ -289,15 +313,65 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
{file.customName || file.displayName}
</h1>
)}
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
onMouseEnter={(e) => {
const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement;
if (btn) btn.style.opacity = '1';
}}
onMouseLeave={(e) => {
const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement;
if (btn) btn.style.opacity = '0';
}}
>
<p
onClick={handleOpenFolder}
style={{ cursor: 'pointer', opacity: 0.7, transition: 'opacity 0.2s ease' }}
style={{ cursor: 'pointer', opacity: 0.7, transition: 'opacity 0.2s ease', margin: 0 }}
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.7'}
title="Click to open in folder"
>
{file.relativePath}
{normalizePathDisplay(file.relativePath)}
</p>
<button
type="button"
className="file-regenerate-name"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
setBusy(true);
try {
await triggerOrganize({}, true);
} finally {
setBusy(false);
}
}}
disabled={busy}
title="Regenerate filename based on current metadata"
style={{
padding: '4px 8px',
fontSize: '18px',
opacity: 0,
transition: 'opacity 0.2s ease',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit'
}}
>
</button>
</div>
{file.parentFileId !== null && onOpenParent ? (
<button
type="button"
className="file-parent-link"
onClick={handleOpenParent}
title={parentFile ? `Open source file ${parentFile.displayName}` : 'Open source file'}
>
🔗 {parentFile ? parentFile.customName || parentFile.displayName : `Source missing (#${file.parentFileId})`}
</button>
) : null}
</div>
<div className="file-meta-grid">
<div>{formatBytes(file.size)}</div>
@@ -371,17 +445,16 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
onClick={toggleTagSection}
aria-expanded={isTagSectionExpanded}
>
<span className="tag-editor-toggle-label">Tags</span>
<span className="tag-editor-toggle-label">Categories</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}
onSave={handleCategorySave}
showHeading={false}
/>
</div>

View File

@@ -9,26 +9,21 @@ export interface FileListProps {
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 {
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: 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(0);
const lastDragX = useRef(0);
const dragRotation = useRef<number>(0);
const lastDragX = useRef<number>(0);
const dropTargetPosition = useRef<{ x: number; y: number } | null>(null);
const dragOffset = useRef({ x: 0, y: 0 });
const dragStartTime = useRef(0);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const dragOffset = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const dragStartTime = useRef<number>(0);
useEffect(() => {
if (selectedId === null) {
@@ -44,6 +39,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
if (isTypingContext || isEditable) {
return;
}
// Keep keyboard focus aligned with the active selection.
button.focus();
}, [selectedId]);
@@ -92,43 +88,6 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
};
}, [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,
@@ -166,7 +125,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
placeholder="Search files..."
/>
</div>
<div className="file-list-items" ref={listContainerRef}>
<div className="file-list-items">
{files.map((file) => {
const isActive = file.id === selectedId;
const isSelected = selectedIds.has(file.id);
@@ -186,6 +145,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
type="button"
className={className}
draggable={true}
data-file-id={file.id}
ref={(node) => {
if (node) {
buttonRefs.current.set(file.id, node);
@@ -196,6 +156,8 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
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({
@@ -204,6 +166,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
}));
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';
@@ -212,6 +175,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
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;
@@ -221,18 +185,21 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
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;
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`;
@@ -256,6 +223,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
rotationWrapper.appendChild(ghost);
document.body.appendChild(rotationWrapper);
// Trigger the offset transition to center
requestAnimationFrame(() => {
if (ghost.parentNode) {
ghost.style.transform = `translate(-50%, -50%)`;
@@ -277,19 +245,25 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
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';
@@ -299,6 +273,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
dragGhostRef.current = null;
}, 300);
} else {
// No drop or cancelled - remove immediately
wrapper.remove();
dragGhostRef.current = null;
}

View File

@@ -6,7 +6,7 @@ import { TagEditor } from './TagEditor';
export interface MultiFileEditorProps {
files: AudioFileSummary[];
categories: CategoryRecord[];
onUpdateTags(fileId: number, data: { tags: string[]; categories: string[] }): Promise<void>;
onUpdateTags(fileId: number, categories: string[]): Promise<void>;
onUpdateCustomName(fileId: number, customName: string | null): Promise<void>;
onOrganize(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
onUpdateMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void>;
@@ -14,7 +14,7 @@ export interface MultiFileEditorProps {
}
/**
* Multi-file tag editor that shows aggregated metadata and allows batch editing.
* Multi-file metadata editor that focuses on shared categories and attributes.
*/
export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCustomName, onOrganize, onUpdateMetadata, metadataSuggestionsVersion }: MultiFileEditorProps): JSX.Element {
const [sharedCustomName, setSharedCustomName] = useState('');
@@ -23,20 +23,6 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
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) {
@@ -199,22 +185,22 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
}
};
const handleTagSave = async (data: { tags: string[]; categories: string[] }) => {
const handleCategorySave = async (selectedCategories: string[]) => {
try {
let failureCount = 0;
for (const file of files) {
try {
await onUpdateTags(file.id, data);
await onUpdateTags(file.id, selectedCategories);
} catch (error) {
failureCount += 1;
console.error(`Failed to update tags for file ${file.id} (${file.fileName}):`, error);
console.error(`Failed to update categories for file ${file.id} (${file.fileName}):`, error);
}
}
if (failureCount > 0) {
console.error(`${failureCount} file(s) failed to update tags`);
console.error(`${failureCount} file(s) failed to update categories`);
}
} catch (error) {
console.error('Failed to update tags:', error);
console.error('Failed to update categories:', error);
}
};
@@ -309,17 +295,16 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
onClick={toggleTagSection}
aria-expanded={isTagSectionExpanded}
>
<span className="tag-editor-toggle-label">Tags</span>
<span className="tag-editor-toggle-label">Categories</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}
onSave={handleCategorySave}
showHeading={false}
/>
</div>

View File

@@ -1,13 +1,4 @@
import {
useEffect,
useMemo,
useState,
useRef,
useLayoutEffect,
type ChangeEvent,
type UIEvent,
type CSSProperties
} from 'react';
import { useEffect, useMemo, useState, useRef, useLayoutEffect, type UIEvent } from 'react';
import type { CategoryRecord } from '../../../shared/models';
import {
getCollapsedGroups,
@@ -24,19 +15,17 @@ import {
import type { CategorySwatch } from '../utils/categoryColors';
export interface TagEditorProps {
tags: string[];
categories: string[];
availableCategories: CategoryRecord[];
onSave(data: { tags: string[]; categories: string[] }): void;
onSave(categories: string[]): void | Promise<void>;
/** If false the internal heading is omitted. Defaults to true. */
showHeading?: boolean;
}
/**
* Allows editing free-form tags and selecting UCS categories.
* Provides category selection controls without exposing free-form tag editing.
*/
export function TagEditor({ tags, categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element {
const [tagDraft, setTagDraft] = useState(tags.join(', '));
export function TagEditor({ categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element {
const [categoryFilter, setCategoryFilter] = useState('');
const [selectedCategories, setSelectedCategories] = useState(new Set(categories));
const [isCategoryListExpanded, setIsCategoryListExpanded] = useState(false);
@@ -52,7 +41,6 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
const isFirstRender = useRef(true);
useEffect(() => {
setTagDraft(tags.join(', '));
setSelectedCategories(new Set(categories));
if (!isFirstRender.current) {
@@ -61,7 +49,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
} else {
isFirstRender.current = false;
}
}, [tags, categories]);
}, [categories]);
const { groupedCategories, filteredResults } = useMemo(() => {
const filter = categoryFilter.trim().toLowerCase();
@@ -83,16 +71,18 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
}, [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;
() => {
// Preserve the order from the categories prop (first category is primary)
const orderedRecords: CategoryRecord[] = [];
for (const catId of categories) {
const record = availableCategories.find((entry) => entry.id === catId);
if (record && selectedCategories.has(catId)) {
orderedRecords.push(record);
}
return left.subCategory.localeCompare(right.subCategory);
}),
[availableCategories, selectedCategories]
}
return orderedRecords;
},
[availableCategories, selectedCategories, categories]
);
const categoryColorMap = useMemo(() => {
@@ -146,20 +136,17 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
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) });
onSave(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 handleSetPrimaryCategory = (categoryId: string) => {
const currentList = Array.from(selectedCategories);
// Remove the category from its current position
const filteredList = currentList.filter((id) => id !== categoryId);
// Add it to the front
const reorderedList = [categoryId, ...filteredList];
setSelectedCategories(new Set(reorderedList));
void onSave(reorderedList);
};
const handleRemoveCategory = (categoryId: string) => {
@@ -174,11 +161,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
return;
}
setSelectedCategories(new Set());
const parsedTags = tagDraft
.split(',')
.map((value: string) => value.trim())
.filter((value: string) => value.length > 0);
onSave({ tags: parsedTags, categories: [] });
onSave([]);
};
const toggleCategoryList = () => {
@@ -187,18 +170,127 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
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}
/>
{showHeading && <h2>Categories</h2>}
<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, index) => {
const categoryLabel = formatCategoryLabel(entry.category);
const subCategoryLabel = formatCategoryLabel(entry.subCategory);
const isPrimary = index === 0;
return (
<div
key={entry.id}
className="selected-category-chip"
style={{
...createCategoryStyleVars(categoryColorMap.get(entry.id)),
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
position: 'relative'
}}
onMouseEnter={(e) => {
const star = e.currentTarget.querySelector('.category-star-button') as HTMLElement;
if (star && !isPrimary) {
star.style.opacity = '1';
star.style.width = 'auto';
star.style.padding = '0 4px';
}
const removeBtn = e.currentTarget.querySelector('.selected-category-chip-remove-button') as HTMLElement;
if (removeBtn) {
removeBtn.style.opacity = '1';
removeBtn.style.width = 'auto';
removeBtn.style.padding = '0 4px';
}
}}
onMouseLeave={(e) => {
const star = e.currentTarget.querySelector('.category-star-button') as HTMLElement;
if (star && !isPrimary) {
star.style.opacity = '0';
star.style.width = '0';
star.style.padding = '0';
}
const removeBtn = e.currentTarget.querySelector('.selected-category-chip-remove-button') as HTMLElement;
if (removeBtn) {
removeBtn.style.opacity = '0';
removeBtn.style.width = '0';
removeBtn.style.padding = '0';
}
}}
title={isPrimary ? `${categoryLabel} ${subCategoryLabel} (Primary - used for organization)` : `${categoryLabel} ${subCategoryLabel}`}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetPrimaryCategory(entry.id);
}}
className="category-star-button"
style={{
background: 'none',
border: 'none',
padding: isPrimary ? '0 4px' : '0',
margin: 0,
cursor: 'pointer',
fontSize: '14px',
opacity: isPrimary ? 1 : 0,
width: isPrimary ? 'auto' : '0',
overflow: 'hidden',
transition: 'opacity 0.2s ease, width 0.2s ease, padding 0.2s ease',
color: isPrimary ? 'gold' : 'currentColor',
flexShrink: 0
}}
title={isPrimary ? 'Primary category' : 'Set as primary category'}
aria-label={isPrimary ? 'Primary category' : 'Set as primary category'}
>
{isPrimary ? '★' : '☆'}
</button>
<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>
<button
type="button"
className="selected-category-chip-remove-button"
onClick={(e) => {
e.stopPropagation();
handleRemoveCategory(entry.id);
}}
style={{
background: 'none',
border: 'none',
padding: '0',
margin: 0,
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1,
opacity: 0,
width: '0',
overflow: 'hidden',
transition: 'opacity 0.2s ease, width 0.2s ease, padding 0.2s ease',
flexShrink: 0
}}
aria-label="Remove category"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
})}
</div>
<button
type="button"
className="ghost-button selected-category-clear"
onClick={handleClearCategories}
disabled={selectedCategories.size === 0}
>
Clear All
</button>
</div>
<div className="category-filter">
<input
value={categoryFilter}
onChange={(event: ChangeEvent<HTMLInputElement>) => setCategoryFilter(event.target.value)}
onChange={(event) => setCategoryFilter(event.target.value)}
placeholder="Filter UCS categories"
/>
</div>
@@ -234,39 +326,6 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
<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"

View File

@@ -0,0 +1,879 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AudioFileSummary, CategoryRecord } from '../../../../shared/models';
import { TagEditor } from '../TagEditor';
import WaveformEditorCanvas from './WaveformEditorCanvas';
import { libraryStore } from '../../stores/LibraryStore';
import { toSplitRequest, type SegmentDraft, type SegmentMetadataDraft } from './types';
export interface EditModePanelProps {
/** File currently being edited. */
file: AudioFileSummary;
/** Available category catalog used by the tag editor. */
categories: CategoryRecord[];
/** Invoked after the panel should be closed without committing. */
onClose(): void;
/** Invoked after a successful split with the newly created files. */
onSplitComplete(created: AudioFileSummary[]): void;
}
const MIN_SEGMENT_MS = 50;
type PlaybackMode = 'segment' | 'cursor';
interface PlaybackInfo {
mode: PlaybackMode;
startMs: number;
endMs: number | null;
label: string;
segmentId: string | null;
pausedOffsetMs: number;
}
interface PlaybackUiState {
mode: PlaybackMode;
label: string;
startMs: number;
endMs: number | null;
isPlaying: boolean;
}
/**
* Full-screen overlay that enables waveform driven segment editing and metadata assignment before splitting.
*/
export function EditModePanel({ file, categories, onClose, onSplitComplete }: EditModePanelProps): JSX.Element {
const [waveformSamples, setWaveformSamples] = useState<number[] | null>(null);
const [waveformError, setWaveformError] = useState<string | null>(null);
const [durationMs, setDurationMs] = useState<number | null>(file.durationMs ?? null);
const [segments, setSegments] = useState<SegmentDraft[]>([]);
const [selectedSegmentId, setSelectedSegmentId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [baseMetadata, setBaseMetadata] = useState<{ author: string | null; rating: number | null }>(
() => ({ author: null, rating: null })
);
const audioContextRef = useRef<AudioContext | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
const audioBufferPromiseRef = useRef<Promise<AudioBuffer | null> | null>(null);
const activeSourceRef = useRef<AudioBufferSourceNode | null>(null);
const playbackStartTimeRef = useRef<number | null>(null);
const playbackInfoRef = useRef<PlaybackInfo | null>(null);
const activePlaybackIdRef = useRef<string | null>(null);
const lastSegmentPlayRef = useRef<{ segmentId: string; timestamp: number } | null>(null);
const playbackCursorFrameRef = useRef<number | null>(null);
const [playbackCursorMs, setPlaybackCursorMs] = useState<number | null>(null);
const [playbackUi, setPlaybackUi] = useState<PlaybackUiState | null>(null);
const ensureAudioContext = useCallback((): AudioContext | null => {
if (audioContextRef.current) {
return audioContextRef.current;
}
const globalWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext };
const AudioContextCtor = globalWindow.AudioContext ?? globalWindow.webkitAudioContext;
if (!AudioContextCtor) {
setWaveformError((current) => current ?? 'Audio playback is not supported in this environment.');
return null;
}
audioContextRef.current = new AudioContextCtor();
return audioContextRef.current;
}, []);
const stopActiveSource = useCallback(() => {
const source = activeSourceRef.current;
if (!source) {
return;
}
source.onended = null;
try {
source.stop();
} catch (error) {
// Ignore errors when the source has already completed playback.
}
source.disconnect();
activeSourceRef.current = null;
}, []);
const stopPlaybackCursorTracking = useCallback((options?: { preservePosition?: boolean }) => {
if (playbackCursorFrameRef.current !== null) {
cancelAnimationFrame(playbackCursorFrameRef.current);
playbackCursorFrameRef.current = null;
}
if (!options?.preservePosition) {
setPlaybackCursorMs(null);
}
}, []);
const schedulePlaybackCursorUpdate = useCallback(() => {
const context = audioContextRef.current;
const info = playbackInfoRef.current;
if (!context || !info || playbackStartTimeRef.current === null) {
stopPlaybackCursorTracking();
return;
}
const elapsedMs = Math.max(0, (context.currentTime - playbackStartTimeRef.current) * 1000);
const buffer = audioBufferRef.current;
const bufferDurationMs = buffer ? buffer.duration * 1000 : Number.POSITIVE_INFINITY;
const absoluteEndMs = info.endMs ?? bufferDurationMs;
const position = info.startMs + info.pausedOffsetMs + elapsedMs;
const clampedPosition = Math.min(position, absoluteEndMs);
setPlaybackCursorMs(clampedPosition);
if (position >= absoluteEndMs) {
stopPlaybackCursorTracking({ preservePosition: true });
return;
}
playbackCursorFrameRef.current = window.requestAnimationFrame(schedulePlaybackCursorUpdate);
}, [stopPlaybackCursorTracking]);
const cancelPlayback = useCallback(() => {
stopActiveSource();
activePlaybackIdRef.current = null;
playbackStartTimeRef.current = null;
playbackInfoRef.current = null;
stopPlaybackCursorTracking();
setPlaybackUi(null);
}, [stopActiveSource, stopPlaybackCursorTracking]);
const loadAudioBuffer = useCallback(async (): Promise<AudioBuffer | null> => {
if (audioBufferRef.current) {
return audioBufferRef.current;
}
if (audioBufferPromiseRef.current) {
return audioBufferPromiseRef.current;
}
const context = ensureAudioContext();
if (!context) {
return null;
}
const promise = (async () => {
try {
const payload = await window.api.getAudioBuffer(file.id);
const buffer = await context.decodeAudioData(payload.buffer.slice(0));
audioBufferRef.current = buffer;
return buffer;
} catch (error) {
console.error('Failed to decode audio buffer for playback', error);
return null;
} finally {
audioBufferPromiseRef.current = null;
}
})();
audioBufferPromiseRef.current = promise;
return promise;
}, [ensureAudioContext, file.id]);
const beginPlayback = useCallback(
async (request: PlaybackInfo) => {
try {
const buffer = await loadAudioBuffer();
if (!buffer) {
return;
}
const context = ensureAudioContext();
if (!context) {
return;
}
await context.resume();
stopActiveSource();
const bufferDurationMs = buffer.duration * 1000;
const absoluteEndMs = request.endMs ?? bufferDurationMs;
const playbackStartMs = request.startMs + request.pausedOffsetMs;
if (playbackStartMs >= absoluteEndMs) {
return;
}
const id = generatePlaybackId();
const playbackLengthMs = absoluteEndMs - playbackStartMs;
const source = context.createBufferSource();
source.buffer = buffer;
// Create a gain node for fade control
const gainNode = context.createGain();
source.connect(gainNode);
gainNode.connect(context.destination);
// Apply fade in/out if playing a segment
if (request.mode === 'segment' && request.segmentId) {
const segment = segments.find((s) => s.id === request.segmentId);
if (segment) {
const fadeInMs = Math.min(segment.fadeInMs, playbackLengthMs / 2);
const fadeOutMs = Math.min(segment.fadeOutMs, playbackLengthMs / 2);
const currentTime = context.currentTime;
// Set initial gain
gainNode.gain.setValueAtTime(fadeInMs > 0 ? 0 : 1, currentTime);
// Apply fade in
if (fadeInMs > 0) {
const fadeInEndTime = currentTime + (fadeInMs / 1000);
gainNode.gain.linearRampToValueAtTime(1, fadeInEndTime);
}
// Apply fade out
if (fadeOutMs > 0) {
const fadeOutStartTime = currentTime + ((playbackLengthMs - fadeOutMs) / 1000);
const fadeOutEndTime = currentTime + (playbackLengthMs / 1000);
gainNode.gain.setValueAtTime(1, fadeOutStartTime);
gainNode.gain.linearRampToValueAtTime(0, fadeOutEndTime);
}
}
}
source.start(0, playbackStartMs / 1000, playbackLengthMs / 1000);
playbackInfoRef.current = { ...request };
activePlaybackIdRef.current = id;
playbackStartTimeRef.current = context.currentTime;
activeSourceRef.current = source;
stopPlaybackCursorTracking({ preservePosition: true });
setPlaybackCursorMs(request.startMs + request.pausedOffsetMs);
schedulePlaybackCursorUpdate();
setPlaybackUi({
mode: request.mode,
label: request.label,
startMs: request.startMs,
endMs: request.endMs,
isPlaying: true
});
source.onended = () => {
if (activePlaybackIdRef.current === id) {
const infoSnapshot = playbackInfoRef.current;
const buffer = audioBufferRef.current;
const bufferDurationMs = buffer ? buffer.duration * 1000 : Number.POSITIVE_INFINITY;
const finalPosition = infoSnapshot ? (infoSnapshot.endMs ?? bufferDurationMs) : null;
if (finalPosition !== null && Number.isFinite(finalPosition)) {
setPlaybackCursorMs(finalPosition);
}
stopPlaybackCursorTracking({ preservePosition: true });
activePlaybackIdRef.current = null;
playbackStartTimeRef.current = null;
playbackInfoRef.current = null;
activeSourceRef.current = null;
setPlaybackUi(null);
}
};
} catch (error) {
console.error('Playback start failed', error);
stopPlaybackCursorTracking();
}
},
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking, segments]
);
const pausePlayback = useCallback(() => {
const info = playbackInfoRef.current;
const context = audioContextRef.current;
if (!info || !context) {
return;
}
if (activePlaybackIdRef.current === null || playbackStartTimeRef.current === null) {
return;
}
const elapsedMs = (context.currentTime - playbackStartTimeRef.current) * 1000;
const bufferDurationMs = audioBufferRef.current ? audioBufferRef.current.duration * 1000 : Number.POSITIVE_INFINITY;
const absoluteEndMs = info.endMs ?? bufferDurationMs;
const nextOffset = Math.min(absoluteEndMs - info.startMs, info.pausedOffsetMs + elapsedMs);
const absolutePosition = info.startMs + nextOffset;
playbackInfoRef.current = { ...info, pausedOffsetMs: nextOffset };
activePlaybackIdRef.current = null;
playbackStartTimeRef.current = null;
stopActiveSource();
stopPlaybackCursorTracking({ preservePosition: true });
setPlaybackCursorMs(absolutePosition);
setPlaybackUi((current) => (current ? { ...current, isPlaying: false } : current));
}, [stopActiveSource, stopPlaybackCursorTracking]);
const resumePlayback = useCallback(() => {
const info = playbackInfoRef.current;
if (!info) {
return;
}
beginPlayback(info);
}, [beginPlayback]);
const togglePlayback = useCallback(() => {
if (activePlaybackIdRef.current) {
pausePlayback();
return;
}
if (playbackInfoRef.current) {
resumePlayback();
}
}, [pausePlayback, resumePlayback]);
useEffect(() => {
let cancelled = false;
setWaveformSamples(null);
setWaveformError(null);
(async () => {
try {
const preview = await window.api.getWaveformPreview(file.id, 8192);
if (!cancelled) {
setWaveformSamples(preview.samples);
}
} catch (error) {
console.error('Failed to load waveform preview', error);
if (!cancelled) {
setWaveformSamples([]);
setWaveformError('Failed to load waveform preview for this file.');
}
}
})();
return () => {
cancelled = true;
};
}, [file.id]);
useEffect(() => {
let cancelled = false;
setSegments([]);
setSelectedSegmentId(null);
(async () => {
try {
const metadata = await window.api.readFileMetadata(file.id);
if (!cancelled) {
setBaseMetadata({
author: metadata.author?.trim() ?? null,
rating: metadata.rating ?? null
});
}
} catch (error) {
console.warn('Failed to read metadata for edit mode', error);
if (!cancelled) {
setBaseMetadata({ author: null, rating: null });
}
}
})();
return () => {
cancelled = true;
};
}, [file.id]);
useEffect(() => {
let cancelled = false;
(async () => {
const buffer = await loadAudioBuffer();
if (cancelled || !buffer) {
return;
}
setDurationMs((current) => (current === null ? Math.round(buffer.duration * 1000) : current));
})();
return () => {
cancelled = true;
};
}, [loadAudioBuffer]);
useEffect(() => {
return () => {
cancelPlayback();
const context = audioContextRef.current;
if (context) {
context.close().catch(() => undefined);
audioContextRef.current = null;
}
};
}, [cancelPlayback]);
const selectedSegment = useMemo(
() => segments.find((segment) => segment.id === selectedSegmentId) ?? null,
[segments, selectedSegmentId]
);
const isWaveformReady = waveformSamples !== null && durationMs !== null;
const effectiveDuration = durationMs ?? 0;
const playSegment = useCallback(
(segment: SegmentDraft) => {
const trimmedLabel = segment.label.trim();
const label = trimmedLabel.length > 0
? trimmedLabel
: `${formatTimecode(segment.startMs)}${formatTimecode(segment.endMs)}`;
beginPlayback({
mode: 'segment',
startMs: segment.startMs,
endMs: segment.endMs,
label,
segmentId: segment.id,
pausedOffsetMs: 0
});
},
[beginPlayback]
);
const playFromCursor = useCallback(
(startMs: number) => {
beginPlayback({
mode: 'cursor',
startMs,
endMs: null,
label: `From ${formatTimecode(startMs)}`,
segmentId: null,
pausedOffsetMs: 0
});
},
[beginPlayback]
);
const handleClose = useCallback(() => {
cancelPlayback();
onClose();
}, [cancelPlayback, onClose]);
const handleCreateSegment = (rawStart: number, rawEnd: number) => {
if (!isWaveformReady) {
return;
}
const { start, end } = normaliseRange(rawStart, rawEnd, effectiveDuration);
const newSegment: SegmentDraft = {
id: generateSegmentId(),
startMs: start,
endMs: end,
label: `Segment ${segments.length + 1}`,
metadata: buildDefaultMetadata(file, baseMetadata),
color: generateSegmentColor(segments.length),
fadeInMs: 0,
fadeOutMs: 0
};
setSegments((current) => {
const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs);
return next;
});
setSelectedSegmentId(newSegment.id);
playSegment(newSegment);
};
const handleResizeSegment = (segmentId: string, rawStart: number, rawEnd: number) => {
if (!isWaveformReady) {
return;
}
const { start, end } = normaliseRange(rawStart, rawEnd, effectiveDuration);
setSegments((current) => {
const next = current.map((segment) =>
segment.id === segmentId
? { ...segment, startMs: start, endMs: end }
: segment
);
next.sort((a, b) => a.startMs - b.startMs);
return next;
});
};
const handleSelectSegment = useCallback((segmentId: string | null) => {
setSelectedSegmentId(segmentId);
if (!segmentId) {
return;
}
const segment = segments.find((entry) => entry.id === segmentId);
if (segment) {
const now = performance.now();
const lastPlay = lastSegmentPlayRef.current;
const recentlyStartedSameSegment =
lastPlay &&
lastPlay.segmentId === segmentId &&
now - lastPlay.timestamp < 500;
if (!recentlyStartedSameSegment) {
playSegment(segment);
lastSegmentPlayRef.current = {
segmentId,
timestamp: now
};
}
}
}, [segments, playSegment]);
const handleUpdateLabel = (segmentId: string, label: string) => {
setSegments((current) =>
current.map((segment) =>
segment.id === segmentId
? { ...segment, label }
: segment
)
);
};
const handleUpdateFade = useCallback((segmentId: string, fadeInMs: number, fadeOutMs: number) => {
setSegments((current) =>
current.map((segment) =>
segment.id === segmentId
? { ...segment, fadeInMs, fadeOutMs }
: segment
)
);
}, []);
const handleRemoveSegment = useCallback((segmentId: string) => {
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
setSelectedSegmentId((current) => (current === segmentId ? null : current));
if (playbackInfoRef.current?.segmentId === segmentId) {
cancelPlayback();
}
}, [cancelPlayback]);
const handleMetadataPatch = (segmentId: string, patch: Partial<SegmentMetadataDraft>) => {
setSegments((current) =>
current.map((segment) =>
segment.id === segmentId
? { ...segment, metadata: { ...segment.metadata, ...patch } }
: segment
)
);
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.getAttribute('contenteditable') === 'true')) {
return;
}
if (event.key === 'Delete') {
if (selectedSegmentId) {
event.preventDefault();
handleRemoveSegment(selectedSegmentId);
}
return;
}
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault();
if (playbackInfoRef.current) {
togglePlayback();
} else if (selectedSegmentId) {
const segment = segments.find((seg) => seg.id === selectedSegmentId);
if (segment) {
playSegment(segment);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [selectedSegmentId, segments, handleRemoveSegment, togglePlayback, playSegment]);
const handleCategorySave = (segmentId: string, categories: string[]) => {
handleMetadataPatch(segmentId, {
categories
});
};
const handleCommit = async () => {
if (segments.length === 0 || !isWaveformReady) {
return;
}
setSubmitError(null);
setIsSaving(true);
try {
const payload = segments.map((segment) => toSplitRequest(segment));
const created = await libraryStore.splitFile(file.id, payload);
onSplitComplete(created);
handleClose();
} catch (error) {
console.error('Split operation failed', error);
setSubmitError(error instanceof Error ? error.message : String(error));
} finally {
setIsSaving(false);
}
};
return (
<div className="edit-mode-overlay" role="dialog" aria-modal="true">
<div className="edit-mode-panel">
<header className="edit-mode-header">
<div>
<h1>{file.customName || file.displayName}</h1>
<p>{file.relativePath}</p>
</div>
<div className="edit-mode-header-actions">
<button type="button" className="ghost-button" onClick={handleClose}>Close</button>
</div>
</header>
<div className="edit-mode-body">
<div className="edit-mode-waveform-container">
<div className="edit-mode-waveform">
{waveformError && <div className="edit-mode-error">{waveformError}</div>}
{!waveformError && !isWaveformReady && (
<div className="edit-mode-placeholder">Loading waveform</div>
)}
{!waveformError && isWaveformReady && waveformSamples && (
<WaveformEditorCanvas
samples={waveformSamples}
durationMs={effectiveDuration}
segments={segments}
selectedSegmentId={selectedSegmentId}
onSelectSegment={handleSelectSegment}
onCreateSegment={handleCreateSegment}
onResizeSegment={handleResizeSegment}
onPlayFromCursor={playFromCursor}
playbackCursorMs={playbackCursorMs}
onUpdateFade={handleUpdateFade}
/>
)}
</div>
<div className="edit-mode-playback-controls">
{playbackUi ? (
<>
<button
type="button"
className="ghost-button edit-mode-playback-toggle"
onClick={togglePlayback}
>
{playbackUi.isPlaying ? 'Pause (Space)' : 'Play (Space)'}
</button>
<div className="edit-mode-playback-details">
<span className="edit-mode-playback-title">{playbackUi.label}</span>
<span className="edit-mode-playback-range">
{formatTimecode(playbackUi.startMs)}
{' → '}
{formatTimecode(playbackUi.endMs ?? effectiveDuration)}
{playbackUi.endMs === null ? ' (end)' : ''}
</span>
</div>
</>
) : (
<>
{selectedSegment ? (
<>
<button
type="button"
className="ghost-button edit-mode-playback-toggle"
onClick={() => playSegment(selectedSegment)}
>
Play (Space)
</button>
<div className="edit-mode-playback-details">
<span className="edit-mode-playback-title">
{selectedSegment.label.trim() || `${formatTimecode(selectedSegment.startMs)}${formatTimecode(selectedSegment.endMs)}`}
</span>
<span className="edit-mode-playback-range">
{formatTimecode(selectedSegment.startMs)}
{' → '}
{formatTimecode(selectedSegment.endMs)}
</span>
</div>
</>
) : (
<div className="edit-mode-playback-idle">No segment selected</div>
)}
</>
)}
</div>
</div>
<aside className="edit-mode-sidebar">
<section className="segment-list">
<header className="segment-list-header">
<h2>Segments</h2>
<span>{segments.length}</span>
</header>
{segments.length === 0 ? (
<p className="segment-list-empty">Drag on the waveform to create segments.</p>
) : (
<ul>
{segments.map((segment) => (
<li key={segment.id} className={segment.id === selectedSegmentId ? 'segment-item segment-item--selected' : 'segment-item'}>
<span className="segment-color-indicator" style={{ backgroundColor: segment.color }} />
<button
type="button"
className="segment-item-select"
onClick={() => handleSelectSegment(segment.id)}
aria-pressed={segment.id === selectedSegmentId}
>
<span className="segment-item-time">{formatTimecode(segment.startMs)} {formatTimecode(segment.endMs)}</span>
</button>
<div className="segment-item-label-row">
<input
type="text"
value={segment.label}
onChange={(event) => handleUpdateLabel(segment.id, event.target.value)}
placeholder="Segment Name"
/>
{segment.id === selectedSegmentId && (
<button
type="button"
className="segment-item-delete"
onClick={() => handleRemoveSegment(segment.id)}
aria-label="Delete segment"
title="Delete segment"
>
🗑
</button>
)}
</div>
{segment.id === selectedSegmentId && (
<div className="segment-item-metadata">
<label className="segment-field">
<span>Author</span>
<input
type="text"
value={segment.metadata.author ?? ''}
onChange={(event) => handleMetadataPatch(segment.id, { author: normaliseText(event.target.value) })}
placeholder="Artist or creator"
/>
</label>
<div className="segment-field">
<span>Rating</span>
<div className="star-rating">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
className={segment.metadata.rating !== null && segment.metadata.rating >= star ? 'star star--filled' : 'star'}
onClick={() => handleMetadataPatch(segment.id, {
rating: segment.metadata.rating === star ? null : star
})}
aria-label={`${star} star${star > 1 ? 's' : ''}`}
>
</button>
))}
</div>
</div>
<div className="segment-field">
<span>Categories</span>
<TagEditor
categories={segment.metadata.categories}
availableCategories={categories}
onSave={(nextCategories) => handleCategorySave(segment.id, nextCategories)}
showHeading={false}
/>
</div>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="segment-metadata">
<h2>Metadata</h2>
{selectedSegment ? (
<p className="segment-metadata-helper">Edit segment metadata in the segment list above.</p>
) : (
<p className="segment-metadata-empty">Select a segment to edit its metadata.</p>
)}
</section>
</aside>
</div>
<footer className="edit-mode-footer">
{submitError && <span className="edit-mode-error">{submitError}</span>}
<div className="edit-mode-footer-actions">
<button type="button" className="ghost-button" onClick={handleClose} disabled={isSaving}>Cancel</button>
<button
type="button"
className="primary-button"
onClick={handleCommit}
disabled={isSaving || segments.length === 0 || !isWaveformReady}
>
{isSaving ? 'Splitting…' : `Create ${segments.length} segment${segments.length === 1 ? '' : 's'}`}
</button>
</div>
</footer>
</div>
</div>
);
}
function normaliseRange(start: number, end: number, duration: number): { start: number; end: number } {
let safeStart = Math.max(0, Math.min(start, duration - MIN_SEGMENT_MS));
let safeEnd = Math.max(safeStart + MIN_SEGMENT_MS, Math.min(end, duration));
if (safeEnd > duration) {
safeEnd = duration;
safeStart = Math.max(0, safeEnd - MIN_SEGMENT_MS);
}
return { start: safeStart, end: safeEnd };
}
function generatePlaybackId(): string {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return `playback-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function buildDefaultMetadata(
file: AudioFileSummary,
baseMetadata: { author: string | null; rating: number | null }
): SegmentMetadataDraft {
return {
customName: null,
author: baseMetadata.author,
rating: baseMetadata.rating,
tags: file.tags.slice(),
categories: file.categories.slice()
};
}
function generateSegmentId(): string {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return `segment-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function generateSegmentColor(index: number): string {
// Use 36-degree spacing on hue wheel (360/36 = 10 unique colors per cycle)
const hue = (index * 36) % 360;
const saturation = 70;
const lightness = 65;
return hslToHex(hue, saturation, lightness);
}
function hslToHex(h: number, s: number, l: number): string {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lNorm - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else if (h >= 300 && h < 360) {
r = c; g = 0; b = x;
}
const toHex = (n: number) => {
const hex = Math.round((n + m) * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function normaliseText(value: string): string | null {
const trimmed = value.trim();
return trimmed.length === 0 ? null : trimmed;
}
function formatTimecode(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) {
return '00:00.000';
}
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor(ms % 1000);
return `${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
}
export default EditModePanel;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import type { SplitSegmentRequest } from '../../../../shared/models';
/**
* Editable metadata captured for each draft segment.
*/
export interface SegmentMetadataDraft {
/** Custom name override applied to the generated segment (null clears). */
customName: string | null;
/** Author/artist override applied to the generated segment. */
author: string | null;
/** Rating override (1-5) applied to the generated segment, null preserves none. */
rating: number | null;
/** Tag collection applied to the generated segment. */
tags: string[];
/** UCS categories applied to the generated segment. */
categories: string[];
}
/**
* Draft segment structure maintained inside the edit mode panel before saving.
*/
export interface SegmentDraft {
/** Stable identifier used for React rendering and user interactions. */
id: string;
/** Inclusive start offset in milliseconds. */
startMs: number;
/** Exclusive end offset in milliseconds. */
endMs: number;
/** User facing label displayed in the segment list. */
label: string;
/** Captured metadata overrides. */
metadata: SegmentMetadataDraft;
/** Color used for visual identification in UI and waveform. */
color: string;
/** Fade in duration in milliseconds. */
fadeInMs: number;
/** Fade out duration in milliseconds. */
fadeOutMs: number;
}
/**
* Converts a draft segment into the IPC request payload.
*/
export function toSplitRequest(segment: SegmentDraft): SplitSegmentRequest {
const trimmedLabel = segment.label.trim();
const effectiveLabel = trimmedLabel.length > 0 ? trimmedLabel : undefined;
// Only include customName in metadata if it differs from the label
// This allows the backend to use the label as the customName by default
const customNameOverride = segment.metadata.customName !== null && segment.metadata.customName !== trimmedLabel
? segment.metadata.customName
: undefined;
return {
startMs: Math.round(segment.startMs),
endMs: Math.round(segment.endMs),
label: effectiveLabel,
metadata: {
customName: customNameOverride,
author: segment.metadata.author,
rating: segment.metadata.rating ?? undefined,
tags: segment.metadata.tags,
categories: segment.metadata.categories
},
fadeInMs: segment.fadeInMs > 0 ? Math.round(segment.fadeInMs) : undefined,
fadeOutMs: segment.fadeOutMs > 0 ? Math.round(segment.fadeOutMs) : undefined
};
}

View File

@@ -2,14 +2,18 @@ import type {
AppSettings,
AudioFileSummary,
CategoryRecord,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
} from '../../../shared/models';
type ChangeListener = () => void;
export const CATEGORY_FILTER_UNTAGGED = 'untagged' as const;
export type CategoryFilterValue = string | typeof CATEGORY_FILTER_UNTAGGED | null;
/** Filter key that exposes files generated by the most recent split action. */
export const CATEGORY_FILTER_JUST_SPLIT = 'just-split' as const;
export type CategoryFilterValue = string | typeof CATEGORY_FILTER_UNTAGGED | typeof CATEGORY_FILTER_JUST_SPLIT | null;
export interface LibrarySnapshot {
initialized: boolean;
@@ -24,6 +28,8 @@ export interface LibrarySnapshot {
categoryFilter: CategoryFilterValue;
lastScan: LibraryScanSummary | null;
metadataSuggestionsVersion: number;
/** Files created by the most recent split action during this app session. */
justSplitFileIds: number[];
}
/**
@@ -42,7 +48,8 @@ export class LibraryStore extends EventTarget {
searchQuery: '',
categoryFilter: null,
lastScan: null,
metadataSuggestionsVersion: 0
metadataSuggestionsVersion: 0,
justSplitFileIds: []
};
/**
@@ -54,8 +61,8 @@ export class LibraryStore extends EventTarget {
window.api.listCategories(),
window.api.listAudioFiles()
]);
const firstFileId = files.at(0)?.id ?? null;
const firstFile = files.at(0) ?? null;
const firstFileId = files[0]?.id ?? null;
const firstFile = files[0] ?? null;
const nextVersion = this.snapshot.metadataSuggestionsVersion + 1;
this.snapshot = {
initialized: true,
@@ -69,7 +76,8 @@ export class LibraryStore extends EventTarget {
searchQuery: '',
categoryFilter: null,
lastScan: null,
metadataSuggestionsVersion: nextVersion
metadataSuggestionsVersion: nextVersion,
justSplitFileIds: []
};
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
}
@@ -206,6 +214,36 @@ export class LibraryStore extends EventTarget {
this.emitChange();
}
/**
* Clears active filters, refreshes the file cache, and selects the requested file.
*/
public async focusOnFile(fileId: number): Promise<void> {
if (!Number.isFinite(fileId)) {
return;
}
const files = await window.api.listAudioFiles();
if (!files.some((file) => file.id === fileId)) {
return;
}
this.snapshot = {
...this.snapshot,
files,
searchQuery: '',
categoryFilter: null
};
this.refreshVisibleFiles(fileId, false);
const focused = this.snapshot.files.find((file) => file.id === fileId) ?? null;
this.snapshot = {
...this.snapshot,
selectedFileId: fileId,
selectedFileIds: new Set([fileId]),
focusedFile: focused
};
this.emitChange();
}
/**
* Executes a fuzzy search request.
*/
@@ -215,7 +253,7 @@ export class LibraryStore extends EventTarget {
...this.snapshot,
files: results,
searchQuery: query,
selectedFileId: results.at(0)?.id ?? null,
selectedFileId: results[0]?.id ?? null,
focusedFile: this.snapshot.focusedFile
};
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
@@ -232,7 +270,10 @@ export class LibraryStore extends EventTarget {
};
let visible: AudioFileSummary[];
if (filter === CATEGORY_FILTER_UNTAGGED) {
if (filter === CATEGORY_FILTER_JUST_SPLIT) {
const justSplitIds = new Set(this.snapshot.justSplitFileIds);
visible = files.filter((file) => justSplitIds.has(file.id));
} else if (filter === CATEGORY_FILTER_UNTAGGED) {
visible = files.filter((file) => file.categories.length === 0);
} else if (filter) {
visible = files.filter((file) => file.categories.includes(filter));
@@ -244,7 +285,8 @@ export class LibraryStore extends EventTarget {
...this.snapshot,
visibleFiles: visible,
selectedFileId,
focusedFile
focusedFile,
justSplitFileIds: this.snapshot.justSplitFileIds
};
this.emitChange();
}
@@ -258,7 +300,7 @@ export class LibraryStore extends EventTarget {
this.snapshot = {
...this.snapshot,
files,
selectedFileId: files.at(0)?.id ?? null,
selectedFileId: files[0]?.id ?? null,
lastScan: summary,
focusedFile: this.snapshot.focusedFile,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
@@ -267,6 +309,44 @@ export class LibraryStore extends EventTarget {
return summary;
}
/**
* Prompts the user to select a folder and imports audio files from it.
*/
public async importFromFolder(): Promise<LibraryImportResult | null> {
const folder = await window.api.selectImportFolder();
if (!folder) {
return null;
}
return this.executeImport([folder]);
}
/**
* Imports from a drive path, refreshing local caches when new files are added.
*/
public async importFromDrive(drivePath: string): Promise<LibraryImportResult> {
return this.executeImport([drivePath]);
}
private async executeImport(sources: string[]): Promise<LibraryImportResult> {
const trimmed = sources.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
if (trimmed.length === 0) {
return { imported: [], skipped: [], failed: [] };
}
const result = await window.api.importExternalSources(trimmed);
if (result.imported.length > 0) {
const files = await window.api.listAudioFiles();
const preferredId = result.imported[0]?.id ?? this.snapshot.selectedFileId ?? null;
this.snapshot = {
...this.snapshot,
files,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
};
this.refreshVisibleFiles(preferredId ?? undefined);
}
return result;
}
/**
* Applies tag mutations to both the backend and the cached snapshot.
*/
@@ -361,6 +441,40 @@ export class LibraryStore extends EventTarget {
this.emitChange();
}
/**
* Splits a file into multiple segments and refreshes the library snapshot.
*/
public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
const created = await window.api.splitFile(fileId, segments);
const files = await window.api.listAudioFiles();
const createdIds = created.map((record) => record.id);
const fileLookup = new Map(files.map((record) => [record.id, record] as const));
// Prefer fresh copies from the library list so downstream consumers see current metadata.
const justSplitRecords: AudioFileSummary[] = createdIds
.map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null)
.filter((record): record is AudioFileSummary => record !== null);
const primaryId = justSplitRecords[0]?.id ?? null;
const nextSelection = new Set<number>(justSplitRecords.map((record) => record.id));
if (primaryId !== null) {
nextSelection.add(primaryId);
}
this.snapshot = {
...this.snapshot,
files,
visibleFiles: justSplitRecords,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1,
categoryFilter: CATEGORY_FILTER_JUST_SPLIT,
justSplitFileIds: justSplitRecords.map((record) => record.id),
selectedFileId: primaryId,
selectedFileIds: nextSelection,
focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords[0] ?? null : null
};
this.emitChange();
return created;
}
/**
* Assigns a new library path and refreshes the cached settings + file list.
*/
@@ -371,7 +485,7 @@ export class LibraryStore extends EventTarget {
...this.snapshot,
settings,
files,
selectedFileId: files.at(0)?.id ?? null,
selectedFileId: files[0]?.id ?? null,
categoryFilter: null,
focusedFile: this.snapshot.focusedFile,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
@@ -405,8 +519,13 @@ export class LibraryStore extends EventTarget {
private refreshVisibleFiles(preferredId?: number | null, emit = true): void {
const { files, categoryFilter } = this.snapshot;
const availableIds = new Set(files.map((file) => file.id));
const justSplitIds = this.snapshot.justSplitFileIds.filter((id) => availableIds.has(id));
let visible: AudioFileSummary[];
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
if (categoryFilter === CATEGORY_FILTER_JUST_SPLIT) {
const justSplitSet = new Set(justSplitIds);
visible = files.filter((file) => justSplitSet.has(file.id));
} else if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
visible = files.filter((file) => file.categories.length === 0);
} else if (categoryFilter) {
visible = files.filter((file) => file.categories.includes(categoryFilter));
@@ -443,7 +562,8 @@ export class LibraryStore extends EventTarget {
visibleFiles: visible,
selectedFileId: nextSelected,
selectedFileIds: nextSelectionSet,
focusedFile: nextFocused
focusedFile: nextFocused,
justSplitFileIds: justSplitIds
};
if (emit) {
@@ -461,7 +581,7 @@ export class LibraryStore extends EventTarget {
}
}
if (enforceVisible) {
return visible.at(0)?.id ?? null;
return visible[0]?.id ?? null;
}
return desiredId;
}

View File

@@ -8,16 +8,6 @@
body {
margin: 0;
background-color: #0c0f13;
user-select: none;
-webkit-user-select: none;
}
input,
textarea,
select,
[contenteditable='true'] {
user-select: text;
-webkit-user-select: text;
}
* {
@@ -184,6 +174,95 @@ select,
color: inherit;
}
.drive-picker {
width: min(28rem, 90vw);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.drive-picker__header h2 {
margin: 0 0 0.35rem 0;
font-size: 1.4rem;
}
.drive-picker__header p {
margin: 0;
opacity: 0.75;
font-size: 0.95rem;
}
.drive-picker__error {
padding: 0.75rem 1rem;
border-radius: 0.6rem;
background: rgba(220, 97, 97, 0.12);
border: 1px solid rgba(220, 97, 97, 0.35);
color: #f5d6d6;
font-size: 0.9rem;
}
.drive-picker__body {
min-height: 6rem;
}
.drive-picker__status {
margin: 0;
opacity: 0.75;
}
.drive-picker__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.drive-picker__drive {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3rem;
padding: 0.7rem 0.9rem;
border-radius: 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: inherit;
font: inherit;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.drive-picker__drive:hover:not(:disabled),
.drive-picker__drive:focus-visible {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.3);
}
.drive-picker__drive:disabled {
opacity: 0.6;
cursor: default;
}
.drive-picker__drive-label {
font-weight: 600;
font-size: 1rem;
}
.drive-picker__drive-hint {
font-size: 0.8rem;
opacity: 0.7;
}
.drive-picker__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.status-banner {
text-align: center;
padding: 0.25rem 1rem;
@@ -318,6 +397,7 @@ select,
.file-list-search .search-input {
width: 100%;
box-sizing: border-box;
}
.file-list-items {
@@ -429,6 +509,55 @@ select,
overflow: hidden;
}
.detail-tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 0.5rem 0 0.5rem;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.detail-tab {
padding: 0.5rem 1.25rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: 0.5rem 0.5rem 0 0;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font: inherit;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.detail-tab:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.8);
}
.detail-tab:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.detail-tab--active {
background: rgba(39, 110, 241, 0.15);
border-color: rgba(39, 110, 241, 0.3);
color: #eef3f8;
}
.detail-tab--active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: #276ef1;
}
.file-detail {
flex: 1;
overflow-y: auto;
@@ -482,6 +611,23 @@ select,
opacity: 0.7;
}
.file-parent-link {
margin-top: 0.25rem;
padding: 0;
border: none;
background: none;
color: #6aa8ff;
font-size: 0.85rem;
cursor: pointer;
text-align: left;
transition: color 0.15s ease;
}
.file-parent-link:hover {
color: #9bc4ff;
text-decoration: underline;
}
.file-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -745,11 +891,11 @@ select,
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.08);
gap: 0.5rem;
border: none;
border-radius: 0.6rem;
padding: 0.65rem 0.75rem;
background: rgba(12, 16, 22, 0.65);
padding: 0;
background: transparent;
}
.selected-category-chip-list {
@@ -768,21 +914,20 @@ select,
display: flex;
align-items: center;
gap: 0.55rem;
border-radius: 999px;
border-radius: 6px;
border: 1px solid var(--category-color-base, rgba(39, 110, 241, 0.5));
background: var(--category-color-soft, rgba(39, 110, 241, 0.2));
color: var(--category-color-text, inherit);
padding: 0.35rem 0.8rem;
padding: 0.35rem 0.5rem;
font-size: 0.82rem;
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
transition: background 0.15s ease;
max-width: 100%;
flex-wrap: wrap;
}
.selected-category-chip:hover {
background: var(--category-color-strong, rgba(39, 110, 241, 0.35));
transform: translateY(-1px);
}
.selected-category-chip-label {
@@ -1564,3 +1709,342 @@ select,
gap: 1rem;
}
}
/* Edit Mode Styles */
.edit-mode-overlay {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.edit-mode-panel {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(12, 16, 22, 0.95);
}
.edit-mode-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.edit-mode-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.edit-mode-header p {
margin: 0.25rem 0 0 0;
font-size: 0.85rem;
opacity: 0.6;
}
.edit-mode-header-actions {
display: flex;
gap: 0.5rem;
}
.edit-mode-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.edit-mode-waveform-container {
display: flex;
flex-direction: column;
height: 540px;
width: 100%;
}
.edit-mode-waveform {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
position: relative;
width: 100%;
min-height: 0;
}
.edit-mode-playback-controls {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: rgba(15, 19, 25, 0.95);
border-top: 1px solid rgba(255, 255, 255, 0.08);
min-height: 3.5rem;
}
.edit-mode-playback-toggle {
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
flex-shrink: 0;
}
.edit-mode-playback-details {
display: flex;
flex-direction: column;
gap: 0.2rem;
flex: 1;
min-width: 0;
}
.edit-mode-playback-title {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-mode-playback-range {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
opacity: 0.7;
}
.edit-mode-playback-idle {
font-size: 0.9rem;
opacity: 0.5;
font-style: italic;
}
.edit-mode-waveform canvas {
width: 100%;
height: 100%;
display: block;
}
.edit-mode-error,
.edit-mode-placeholder {
padding: 2rem;
text-align: center;
opacity: 0.6;
}
.edit-mode-sidebar {
flex-shrink: 0;
max-height: 50%;
overflow-y: auto;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.segment-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.segment-list-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.segment-list-header h2 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.segment-list-header span {
font-size: 0.85rem;
opacity: 0.6;
background: rgba(255, 255, 255, 0.08);
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
}
.segment-list-empty {
padding: 1.5rem;
text-align: center;
opacity: 0.5;
font-size: 0.9rem;
}
.segment-list ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.segment-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.5rem;
transition: all 0.2s ease;
position: relative;
max-width: 100%;
}
.segment-item--selected {
background: rgba(39, 110, 241, 0.15);
border-color: rgba(39, 110, 241, 0.3);
}
.segment-color-indicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 0.5rem 0 0 0.5rem;
}
.segment-item-select {
all: unset;
cursor: pointer;
display: block;
}
.segment-item-time {
font-size: 0.9rem;
font-family: 'Consolas', 'Monaco', monospace;
opacity: 0.9;
}
.segment-item-label-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.segment-item input[type="text"] {
flex: 1;
padding: 0.4rem 0.6rem;
border-radius: 0.35rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(12, 16, 22, 0.9);
color: inherit;
font: inherit;
font-size: 0.9rem;
}
.segment-item-remove {
align-self: flex-start;
font-size: 0.85rem;
padding: 0.3rem 0.6rem;
}
.segment-metadata {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.segment-metadata h2 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.segment-item-delete {
flex-shrink: 0;
background: rgba(255, 59, 59, 0.15);
border: 1px solid rgba(255, 59, 59, 0.3);
color: #ff6b6b;
padding: 0.4rem 0.6rem;
border-radius: 0.35rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.segment-item-delete:hover {
background: rgba(255, 59, 59, 0.25);
border-color: rgba(255, 59, 59, 0.5);
transform: scale(1.05);
}
.segment-item-delete:active {
transform: scale(0.95);
}
.segment-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.segment-field span {
font-size: 0.85rem;
opacity: 0.7;
}
.segment-field input[type="text"],
.segment-field input[type="number"] {
padding: 0.5rem 0.7rem;
border-radius: 0.35rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(12, 16, 22, 0.9);
color: inherit;
font: inherit;
}
.star-rating {
display: flex;
gap: 0.25rem;
}
.star {
all: unset;
cursor: pointer;
font-size: 1.25rem;
opacity: 0.3;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.star:hover {
opacity: 0.6;
transform: scale(1.1);
}
.star--filled {
opacity: 1;
color: #f59e0b;
}
.segment-actions {
display: flex;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
justify-content: flex-end;
flex-shrink: 0;
}

0
src/renderer/src/types/assets.d.ts vendored Normal file
View File

View File

@@ -3,8 +3,11 @@ export const IPC_CHANNELS = {
settingsGet: 'settings:get',
settingsSetLibrary: 'settings:set-library',
dialogSelectLibrary: 'dialog:select-library',
dialogSelectImportFolder: 'dialog:select-import-folder',
systemListDrives: 'system:list-drives',
libraryScan: 'library:scan',
libraryList: 'library:list',
libraryGetById: 'library:get-by-id',
libraryDuplicates: 'library:duplicates',
libraryRename: 'library:rename',
libraryMove: 'library:move',
@@ -12,11 +15,13 @@ export const IPC_CHANNELS = {
libraryCustomName: 'library:custom-name',
libraryOpenFolder: 'library:open-folder',
libraryDelete: 'library:delete',
librarySplit: 'library:split',
libraryBuffer: 'library:buffer',
libraryMetadata: 'library:metadata',
libraryMetadataSuggestions: 'library:metadata-suggestions',
libraryUpdateMetadata: 'library:update-metadata',
libraryWaveformPreview: 'library:waveform-preview',
libraryImport: 'library:import',
tagsUpdate: 'tags:update',
categoriesList: 'categories:list',
searchQuery: 'search:query'
@@ -36,8 +41,16 @@ export interface RendererApi {
setLibraryPath(path: string): Promise<import('./models').AppSettings>;
/** Triggers a manual rescan of the library. */
rescanLibrary(): Promise<import('./models').LibraryScanSummary>;
/** Opens a dialog to pick a folder to import audio from. */
selectImportFolder(): Promise<string | null>;
/** Lists available system drives for drive-level imports. */
listSystemDrives(): Promise<string[]>;
/** Fetches the current list of audio files. */
listAudioFiles(): Promise<import('./models').AudioFileSummary[]>;
/** Imports external audio files into the library. */
importExternalSources(paths: string[]): Promise<import('./models').LibraryImportResult>;
/** Retrieves a single audio file summary by id, or null if it cannot be resolved. */
getAudioFileById(fileId: number): Promise<import('./models').AudioFileSummary | null>;
/** Fetches groups of duplicate files based on checksum. */
listDuplicates(): Promise<{ checksum: string; files: import('./models').AudioFileSummary[] }[]>;
/** Requests a rename for the target file. */
@@ -45,31 +58,39 @@ export interface RendererApi {
/** 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; rating?: number }): Promise<import('./models').AudioFileSummary>;
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>;
/** Splits an audio file into segment files. */
splitFile(fileId: number, segments: import('./models').SplitSegmentRequest[]): Promise<import('./models').AudioFileSummary[]>;
/** 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. */
/** Updates UCS categories for a file (free-form tags are preserved unless explicitly provided). */
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; title?: string; rating?: number }>;
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[] }>;
/** Updates metadata (author, rating) without organizing the file. */
updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void>;
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;
onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void;
}
declare global {

View File

@@ -26,6 +26,8 @@ export interface AudioFileSummary {
bitDepth: number | null;
/** Content checksum used for duplicate detection. */
checksum: string | null;
/** Identifier of the source file this entry originated from, if any. */
parentFileId: number | null;
/** Applied descriptive tags. */
tags: string[];
/** Associated UCS categories. */
@@ -76,6 +78,35 @@ export interface LibraryScanSummary {
total: number;
}
/** Enumerates reasons why an import candidate might be skipped. */
export type ImportSkipReason = 'duplicate' | 'checksum' | 'unsupported' | 'inside-library';
/** Describes a source entry that was skipped during an import run. */
export interface ImportSkipEntry {
/** Absolute path of the skipped file. */
path: string;
/** Reason the file did not qualify for import. */
reason: ImportSkipReason;
}
/** Records a failure encountered while processing an import candidate. */
export interface ImportFailureEntry {
/** Absolute path of the problematic file or directory. */
path: string;
/** Human-readable message explaining the failure. */
message: string;
}
/** Summary returned after importing external audio sources. */
export interface LibraryImportResult {
/** Files successfully copied into the library. */
imported: AudioFileSummary[];
/** Candidates that were skipped with a known reason. */
skipped: ImportSkipEntry[];
/** Candidates that failed due to unexpected errors. */
failed: ImportFailureEntry[];
}
/**
* Shape of persisted application settings.
*/
@@ -90,8 +121,8 @@ export interface AppSettings {
export interface TagUpdatePayload {
/** Target audio file id. */
fileId: number;
/** Free-form tag collection. */
tags: string[];
/** Optional free-form tag collection. When omitted, existing tags are preserved. */
tags?: string[];
/** UCS category identifiers to attach. */
categories: string[];
}
@@ -107,3 +138,37 @@ export interface AudioBufferPayload {
/** MIME type for the audio data. */
mimeType: string;
}
/**
* Optional metadata overrides applied while creating split segments.
*/
export interface SegmentMetadataInput {
/** Overrides the custom name embedded into the segment (null clears it). */
customName?: string | null;
/** Overrides the author/creator metadata. */
author?: string | null;
/** Overrides the rating metadata (1-5). */
rating?: number;
/** Overrides the applied free-form tags. */
tags?: string[];
/** Overrides the applied UCS categories. */
categories?: string[];
}
/**
* Describes a region that should be extracted as a new audio segment.
*/
export interface SplitSegmentRequest {
/** Inclusive start offset in milliseconds. */
startMs: number;
/** Exclusive end offset in milliseconds. */
endMs: number;
/** Optional display label used for segment naming. */
label?: string;
/** Metadata overrides to apply to the generated file. */
metadata?: SegmentMetadataInput;
/** Fade in duration in milliseconds. */
fadeInMs?: number;
/** Fade out duration in milliseconds. */
fadeOutMs?: number;
}

View File

@@ -1,433 +0,0 @@
/**
* 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);
});
});
});

View File

@@ -1,389 +0,0 @@
/**
* 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);
});
});
});

View File

@@ -1,232 +0,0 @@
/**
* 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);
});
});
});

View File

@@ -1,69 +0,0 @@
/**
* 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
*/

View File

@@ -1,245 +0,0 @@
/**
* 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']);
});
});
});

View File

@@ -1,152 +0,0 @@
/**
* 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));
}

BIN
test.wav

Binary file not shown.