mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-25 03:06:02 +10:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6df01061a | |||
| 720fdf8946 | |||
| e3f4bfc7e2 | |||
| 4d3d616574 | |||
| 93d0da3496 | |||
| 761b1574e7 | |||
| 953c22bec3 | |||
| acf4b8d8b7 | |||
| fe38e7e8f2 | |||
| e16abd7370 | |||
| 9ade1cc7dd | |||
| 7ecb65153c | |||
| 65dadc9e3f | |||
| 9e487fed7b | |||
| e16b069ddd | |||
| 947a510646 | |||
| 343efc18ed | |||
| c2df7198bd | |||
| 69d98c1f1a | |||
| ed3d9b60df | |||
| 18a2d3ee26 | |||
| cf6c14c161 | |||
| 524bbdce8a | |||
| 3569a46ebb | |||
| b4bfbd3ef6 | |||
| c33371b206 | |||
| 4639494b9d | |||
| 7308e3c97c | |||
| 5b2090c102 | |||
| e2017fc6ea | |||
| e9c679dfaf | |||
| d0f7b0fe18 | |||
| db1db50302 | |||
| f18fd4155e | |||
| f4bea5e3fd | |||
| 65ab2776eb | |||
| 6515a30a0e | |||
| 781187c427 |
89
.github/workflows/build-all.yml
vendored
Normal file
89
.github/workflows/build-all.yml
vendored
Normal 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 }}
|
||||||
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -64,3 +64,4 @@ test-data/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
.cache/
|
.cache/
|
||||||
|
AudioSort.code-workspace
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -1,7 +1,7 @@
|
|||||||
# AudioSort
|
# AudioSort
|
||||||
[](https://github.com/litruv/AudioSort/actions/workflows/build.yml)
|
[](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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ A desktop application for organizing, tagging, and managing WAV audio files with
|
|||||||
### Powerful Search & Organization
|
### Powerful Search & Organization
|
||||||
- Fuzzy search across filenames, tags, categories, and metadata
|
- Fuzzy search across filenames, tags, categories, and metadata
|
||||||
- Filter by category or view untagged files
|
- Filter by category or view untagged files
|
||||||
|
- Quick "JUST SPLIT" filter surfaces freshly generated segments for rapid QC
|
||||||
- Multi-select support for batch operations
|
- Multi-select support for batch operations
|
||||||
- Custom naming with automatic conflict resolution
|
- Custom naming with automatic conflict resolution
|
||||||
|
|
||||||
@@ -27,27 +28,24 @@ A desktop application for organizing, tagging, and managing WAV audio files with
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Beautiful Interface
|
### Beautiful Interface
|
||||||
- Real-time waveform visualization with dynamic color-coding based on RMS levels
|
- 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
|
- Smooth animations and polished drag-and-drop interactions
|
||||||
- Responsive file list with keyboard navigation (Arrow keys, Ctrl+A)
|
- Responsive file list with keyboard navigation (Arrow keys, Ctrl+A)
|
||||||
- Dark theme optimized for long sessions
|
- Dark theme optimized for long sessions
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 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
|
## Installation
|
||||||
|
|
||||||
@@ -119,8 +117,8 @@ AudioSort/
|
|||||||
│ │ └── hooks/ # Custom React hooks
|
│ │ └── hooks/ # Custom React hooks
|
||||||
│ ├── preload/ # Electron preload scripts
|
│ ├── preload/ # Electron preload scripts
|
||||||
│ └── shared/ # Shared types and IPC definitions
|
│ └── shared/ # Shared types and IPC definitions
|
||||||
├── data/ # User data directory (created at runtime)
|
├── data/
|
||||||
├── UCS.csv # Universal Category System catalog
|
│ └── UCS.csv # Universal Category System catalog
|
||||||
└── repoimages/ # Documentation assets
|
└── repoimages/ # Documentation assets
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -128,22 +126,12 @@ AudioSort/
|
|||||||
|
|
||||||
### Scripts
|
### Scripts
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start development server with hot reload
|
npm run dev # Start development server with hot reload
|
||||||
npm run build # Build renderer and main process
|
npm run build # Build renderer and main process
|
||||||
npm run lint # Type-check with TypeScript
|
npm run lint # Type-check with TypeScript
|
||||||
npm run test # Run test suite
|
npm run pack # Package without installer
|
||||||
npm run pack # Package without installer
|
npm run dist # Create distributable
|
||||||
npm run dist # Create distributable
|
npm run dist:win # Create Windows installer
|
||||||
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
|
## Configuration
|
||||||
@@ -158,4 +146,3 @@ Edit `package.json` `build` section to customize:
|
|||||||
### Database Location
|
### Database Location
|
||||||
User data is stored in:
|
User data is stored in:
|
||||||
- **Windows**: `%APPDATA%/AudioSort/`
|
- **Windows**: `%APPDATA%/AudioSort/`
|
||||||
- **Linux**: `~/.config/AudioSort/`
|
|
||||||
|
|||||||
219
TEST_README.md
219
TEST_README.md
@@ -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
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
78
package.json
78
package.json
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-sort",
|
"name": "audio-sort",
|
||||||
"version": "0.1.1",
|
"version": "0.5.2",
|
||||||
"description": "Electron audio sorting application with fuzzy search, tagging, and library management for WAV files.",
|
"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",
|
"main": "dist/main/main/index.js",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,59 +22,74 @@
|
|||||||
"pack": "npm run build && electron-builder --dir",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"dist:win": "npm run build && electron-builder --win",
|
"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",
|
"dist:linux": "npm run build && electron-builder --linux",
|
||||||
"test:tag": "node --import tsx --test src/test/TagService.test.ts",
|
"postinstall": "patch-package"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^9.4.5",
|
"better-sqlite3": "^12.4.1",
|
||||||
"csv-parse": "^5.5.5",
|
"csv-parse": "^6.1.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.3",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^7.1.0",
|
||||||
"music-metadata": "^10.5.0",
|
"music-metadata": "^11.10.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.0",
|
||||||
|
"standardized-audio-context": "^25.3.77",
|
||||||
"wavefile": "^11.0.0"
|
"wavefile": "^11.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^29.4.4",
|
"electron": "^39.1.2",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"tsx": "^4.15.7",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.1.6"
|
"vite": "^7.2.2"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.audiosort.app",
|
"appId": "com.audiosort.app",
|
||||||
"productName": "AudioSort",
|
"productName": "AudioSort",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"UCS.csv"
|
"data/**/*"
|
||||||
],
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": ["nsis"],
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
],
|
||||||
"icon": "build/icon.ico"
|
"icon": "build/icon.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true
|
"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": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "UCS.csv",
|
"from": "data/UCS.csv",
|
||||||
"to": "UCS.csv"
|
"to": "data/UCS.csv"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
13
patches/better-sqlite3+12.4.1.patch
Normal file
13
patches/better-sqlite3+12.4.1.patch
Normal 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
BIN
repoimages/editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|||||||
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, Menu } from 'electron';
|
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, Menu } from 'electron';
|
||||||
import type { IpcMainInvokeEvent } from 'electron';
|
import type { IpcMainInvokeEvent } from 'electron';
|
||||||
import { IPC_CHANNELS } from '../shared/ipc';
|
import { IPC_CHANNELS } from '../shared/ipc';
|
||||||
import { TagUpdatePayload } from '../shared/models';
|
import { SplitSegmentRequest, TagUpdatePayload } from '../shared/models';
|
||||||
import { DatabaseService } from './services/DatabaseService';
|
import { DatabaseService } from './services/DatabaseService';
|
||||||
import { LibraryService } from './services/LibraryService';
|
import { LibraryService } from './services/LibraryService';
|
||||||
import { SearchService } from './services/SearchService';
|
import { SearchService } from './services/SearchService';
|
||||||
@@ -41,22 +41,28 @@ export class MainApp {
|
|||||||
this.searchService
|
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.ensureCategoriesLoaded(catalogPath);
|
||||||
await this.libraryService.scanLibrary();
|
await this.libraryService.scanLibrary();
|
||||||
|
|
||||||
this.searchService.rebuildIndex();
|
this.searchService.rebuildIndex();
|
||||||
this.registerIpcHandlers();
|
this.registerIpcHandlers();
|
||||||
this.createMenu();
|
await this.createMenu();
|
||||||
this.createWindow();
|
this.createWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the application menu.
|
* Creates the application menu.
|
||||||
*/
|
*/
|
||||||
private createMenu(): void {
|
private async createMenu(): Promise<void> {
|
||||||
const isMac = process.platform === 'darwin';
|
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[] = [
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
...(isMac ? [{
|
...(isMac ? [{
|
||||||
label: app.name,
|
label: app.name,
|
||||||
@@ -79,6 +85,16 @@ export class MainApp {
|
|||||||
{
|
{
|
||||||
label: 'File',
|
label: 'File',
|
||||||
submenu: [
|
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',
|
label: 'Rescan Library',
|
||||||
accelerator: isMac ? 'Cmd+R' : 'Ctrl+R',
|
accelerator: isMac ? 'Cmd+R' : 'Ctrl+R',
|
||||||
@@ -132,16 +148,20 @@ export class MainApp {
|
|||||||
ipcMain.removeHandler(IPC_CHANNELS.settingsGet);
|
ipcMain.removeHandler(IPC_CHANNELS.settingsGet);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.settingsSetLibrary);
|
ipcMain.removeHandler(IPC_CHANNELS.settingsSetLibrary);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectLibrary);
|
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectLibrary);
|
||||||
|
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectImportFolder);
|
||||||
|
ipcMain.removeHandler(IPC_CHANNELS.systemListDrives);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryScan);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryScan);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryList);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryList);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryRename);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryRename);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMove);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryMove);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryOrganize);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryOrganize);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryBuffer);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryBuffer);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
||||||
|
ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
|
||||||
|
ipcMain.removeHandler(IPC_CHANNELS.libraryImport);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
|
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
|
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.searchQuery);
|
ipcMain.removeHandler(IPC_CHANNELS.searchQuery);
|
||||||
@@ -163,7 +183,7 @@ export class MainApp {
|
|||||||
* Creates the renderer window and loads the UI.
|
* Creates the renderer window and loads the UI.
|
||||||
*/
|
*/
|
||||||
private createWindow(): void {
|
private createWindow(): void {
|
||||||
const preloadPath = this.resolvePreloadPath();
|
const preloadPath = this.resolvePreloadPath();
|
||||||
this.mainWindow = new BrowserWindow({
|
this.mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 800,
|
height: 800,
|
||||||
@@ -186,16 +206,18 @@ export class MainApp {
|
|||||||
this.mainWindow = null;
|
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) {
|
if (devServerUrl) {
|
||||||
this.mainWindow.loadURL(devServerUrl).catch((error: unknown) => {
|
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);
|
console.error('Failed to load renderer URL', error);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const rendererIndex = this.resolveRendererIndex();
|
const rendererIndex = this.resolveRendererIndex();
|
||||||
this.mainWindow.loadFile(rendererIndex).catch((error: unknown) => {
|
this.mainWindow
|
||||||
console.error('Failed to load renderer bundle', error);
|
.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;
|
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.libraryScan, async () => this.requireLibrary().scanLibrary());
|
||||||
ipcMain.handle(IPC_CHANNELS.libraryList, async () => this.requireLibrary().listFiles());
|
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.libraryDuplicates, async () => this.requireLibrary().listDuplicates());
|
||||||
ipcMain.handle(IPC_CHANNELS.searchQuery, async (_event: IpcMainInvokeEvent, query: string) =>
|
ipcMain.handle(IPC_CHANNELS.searchQuery, async (_event: IpcMainInvokeEvent, query: string) =>
|
||||||
this.requireSearch().search(query)
|
this.requireSearch().search(query)
|
||||||
@@ -239,8 +275,12 @@ export class MainApp {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.libraryOrganize,
|
IPC_CHANNELS.libraryOrganize,
|
||||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) =>
|
async (
|
||||||
this.requireLibrary().organizeFile(fileId, metadata)
|
_event: IpcMainInvokeEvent,
|
||||||
|
fileId: number,
|
||||||
|
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
|
||||||
|
) =>
|
||||||
|
this.requireLibrary().organizeFile(fileId, metadata)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -257,6 +297,12 @@ export class MainApp {
|
|||||||
this.requireLibrary().deleteFiles(fileIds)
|
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) =>
|
ipcMain.handle(IPC_CHANNELS.libraryBuffer, async (_event: IpcMainInvokeEvent, fileId: number) =>
|
||||||
this.requireLibrary().getAudioBuffer(fileId)
|
this.requireLibrary().getAudioBuffer(fileId)
|
||||||
);
|
);
|
||||||
@@ -283,9 +329,26 @@ export class MainApp {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.libraryUpdateMetadata,
|
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)
|
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();
|
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[] {
|
private buildSearchBases(): string[] {
|
||||||
const bases = new Set<string>();
|
const bases = new Set<string>();
|
||||||
const appPath = app.getAppPath();
|
const appPath = app.getAppPath();
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface FileRecordInput {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
/** Optional category payload (stored as JSON string). */
|
/** Optional category payload (stored as JSON string). */
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
|
/** Optional parent file reference when generated from another file. */
|
||||||
|
parentFileId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRecordRow extends AudioFileSummary {}
|
export interface FileRecordRow extends AudioFileSummary {}
|
||||||
@@ -89,7 +91,8 @@ export class DatabaseService {
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
checksum,
|
checksum,
|
||||||
tags_json,
|
tags_json,
|
||||||
categories_json
|
categories_json,
|
||||||
|
parent_file_id
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@absolutePath,
|
@absolutePath,
|
||||||
@relativePath,
|
@relativePath,
|
||||||
@@ -103,7 +106,8 @@ export class DatabaseService {
|
|||||||
@bitDepth,
|
@bitDepth,
|
||||||
@checksum,
|
@checksum,
|
||||||
@tagsJson,
|
@tagsJson,
|
||||||
@categoriesJson
|
@categoriesJson,
|
||||||
|
@parentFileId
|
||||||
)
|
)
|
||||||
ON CONFLICT(absolute_path) DO UPDATE SET
|
ON CONFLICT(absolute_path) DO UPDATE SET
|
||||||
library_relative_path = excluded.library_relative_path,
|
library_relative_path = excluded.library_relative_path,
|
||||||
@@ -117,7 +121,8 @@ export class DatabaseService {
|
|||||||
bit_depth = excluded.bit_depth,
|
bit_depth = excluded.bit_depth,
|
||||||
checksum = excluded.checksum,
|
checksum = excluded.checksum,
|
||||||
tags_json = CASE WHEN files.tags_json = '[]' THEN excluded.tags_json ELSE files.tags_json END,
|
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 *`
|
RETURNING *`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -132,9 +137,10 @@ export class DatabaseService {
|
|||||||
durationMs: record.durationMs,
|
durationMs: record.durationMs,
|
||||||
sampleRate: record.sampleRate,
|
sampleRate: record.sampleRate,
|
||||||
bitDepth: record.bitDepth,
|
bitDepth: record.bitDepth,
|
||||||
checksum: record.checksum,
|
checksum: record.checksum,
|
||||||
tagsJson: JSON.stringify(record.tags ?? []),
|
tagsJson: JSON.stringify(record.tags ?? []),
|
||||||
categoriesJson: JSON.stringify(record.categories ?? [])
|
categoriesJson: JSON.stringify(record.categories ?? []),
|
||||||
|
parentFileId: record.parentFileId ?? null
|
||||||
}) as DbRow | undefined;
|
}) as DbRow | undefined;
|
||||||
|
|
||||||
if (!row) {
|
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 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(
|
const statement = connection.prepare(
|
||||||
`UPDATE files
|
`UPDATE files
|
||||||
SET tags_json = @tags,
|
SET ${updates.join(', ')}
|
||||||
categories_json = @categories
|
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
RETURNING *`
|
RETURNING *`
|
||||||
);
|
);
|
||||||
const row = statement.get({
|
const row = statement.get(parameters) as DbRow | undefined;
|
||||||
id: fileId,
|
|
||||||
tags: JSON.stringify(tags),
|
|
||||||
categories: JSON.stringify(categories)
|
|
||||||
}) as DbRow | undefined;
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new Error(`File with id ${fileId} not found`);
|
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),
|
sampleRate: row.sample_rate === null ? null : (row.sample_rate as number),
|
||||||
bitDepth: row.bit_depth === null ? null : (row.bit_depth as number),
|
bitDepth: row.bit_depth === null ? null : (row.bit_depth as number),
|
||||||
checksum: typeof row.checksum === 'string' ? (row.checksum as string) : null,
|
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),
|
tags: this.parseJsonArray(row.tags_json),
|
||||||
categories: this.parseJsonArray(row.categories_json),
|
categories: this.parseJsonArray(row.categories_json),
|
||||||
customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null
|
customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null
|
||||||
@@ -444,7 +464,8 @@ export class DatabaseService {
|
|||||||
bit_depth INTEGER,
|
bit_depth INTEGER,
|
||||||
checksum TEXT,
|
checksum TEXT,
|
||||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
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_display_name ON files(display_name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified_at);
|
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', 'created_at', 'INTEGER');
|
||||||
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
|
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
|
||||||
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
|
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
|
||||||
|
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
|
||||||
|
|
||||||
// Create checksum index after ensuring column exists
|
// Create checksum index after ensuring column exists
|
||||||
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');
|
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ import { createHash } from 'node:crypto';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fg from 'fast-glob';
|
import fg from 'fast-glob';
|
||||||
import { parse } from 'csv-parse/sync';
|
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 { DatabaseService, FileRecordInput } from './DatabaseService';
|
||||||
import { SettingsService } from './SettingsService';
|
import { SettingsService } from './SettingsService';
|
||||||
import { TagService } from './TagService';
|
import { TagService } from './TagService';
|
||||||
@@ -12,6 +23,15 @@ import { SearchService } from './SearchService';
|
|||||||
import { WaveFile } from 'wavefile';
|
import { WaveFile } from 'wavefile';
|
||||||
import { OrganizationService } from './OrganizationService';
|
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 {
|
interface CsvCategoryRow {
|
||||||
Category: string;
|
Category: string;
|
||||||
SubCategory: string;
|
SubCategory: string;
|
||||||
@@ -31,7 +51,8 @@ export class LibraryService {
|
|||||||
private readonly organization: OrganizationService;
|
private readonly organization: OrganizationService;
|
||||||
private metadataSuggestionCache: { authors: Set<string> } | null = null;
|
private metadataSuggestionCache: { authors: Set<string> } | null = null;
|
||||||
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
|
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(
|
public constructor(
|
||||||
private readonly database: DatabaseService,
|
private readonly database: DatabaseService,
|
||||||
private readonly settings: SettingsService,
|
private readonly settings: SettingsService,
|
||||||
@@ -49,10 +70,10 @@ export class LibraryService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8');
|
const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8');
|
||||||
const rows = parse(fileContent, {
|
const rows = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
skip_empty_lines: true
|
skip_empty_lines: true
|
||||||
}) as CsvCategoryRow[];
|
}) as CsvCategoryRow[];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const category: CategoryRecord = {
|
const category: CategoryRecord = {
|
||||||
id: row.CatID,
|
id: row.CatID,
|
||||||
@@ -84,11 +105,12 @@ export class LibraryService {
|
|||||||
this.resetMetadataSuggestionsCache();
|
this.resetMetadataSuggestionsCache();
|
||||||
this.waveformPreviewCache.clear();
|
this.waveformPreviewCache.clear();
|
||||||
|
|
||||||
const cleanedTempFiles = await this.cleanupTempFiles();
|
await this.cleanupTempFiles();
|
||||||
const libraryRoot = this.settings.ensureLibraryPath();
|
const libraryRoot = this.settings.ensureLibraryPath();
|
||||||
const existing = this.database.listFiles();
|
const existing = this.database.listFiles();
|
||||||
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
|
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 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 pattern = ['**/*.wav', '**/*.wave'];
|
||||||
const absolutePaths = await fg(pattern, {
|
const absolutePaths = await fg(pattern, {
|
||||||
@@ -117,9 +139,29 @@ export class LibraryService {
|
|||||||
const knownFile = knownByPath ?? knownByChecksum ?? null;
|
const knownFile = knownByPath ?? knownByChecksum ?? null;
|
||||||
const wasKnown = knownFile !== 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);
|
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 = {
|
const record: FileRecordInput = {
|
||||||
absolutePath,
|
absolutePath,
|
||||||
relativePath,
|
relativePath,
|
||||||
@@ -133,14 +175,36 @@ export class LibraryService {
|
|||||||
bitDepth: metadata.bitDepth,
|
bitDepth: metadata.bitDepth,
|
||||||
checksum,
|
checksum,
|
||||||
tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []),
|
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);
|
const upserted = this.database.upsertFile(record);
|
||||||
|
|
||||||
// Update custom name from embedded title if present, otherwise keep existing
|
// Update custom name from embedded title if present, otherwise keep existing
|
||||||
const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
|
const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
|
||||||
if (customName !== upserted.customName) {
|
const finalRecord = customName !== upserted.customName
|
||||||
this.database.updateCustomName(upserted.id, 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) {
|
if (wasKnown) {
|
||||||
@@ -171,6 +235,20 @@ export class LibraryService {
|
|||||||
return this.database.listFiles();
|
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).
|
* Returns groups of files that have identical checksums (potential duplicates).
|
||||||
*/
|
*/
|
||||||
@@ -225,6 +303,138 @@ export class LibraryService {
|
|||||||
return cleanedCount;
|
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.
|
* 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 }> {
|
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
|
||||||
const record = this.database.getFileById(fileId);
|
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);
|
const cacheHit = this.waveformPreviewCache.get(fileId);
|
||||||
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
|
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
|
||||||
return { samples: cacheHit.samples, rms: cacheHit.rms };
|
return { samples: cacheHit.samples, rms: cacheHit.rms };
|
||||||
@@ -302,7 +512,7 @@ export class LibraryService {
|
|||||||
const buffer = await fs.readFile(record.absolutePath);
|
const buffer = await fs.readFile(record.absolutePath);
|
||||||
const wave = new WaveFile(buffer);
|
const wave = new WaveFile(buffer);
|
||||||
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
|
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];
|
const firstChannel = channels[0];
|
||||||
|
|
||||||
if (!firstChannel || firstChannel.length === 0) {
|
if (!firstChannel || firstChannel.length === 0) {
|
||||||
@@ -510,7 +720,9 @@ export class LibraryService {
|
|||||||
categories: updatedRecord.categories,
|
categories: updatedRecord.categories,
|
||||||
title: effectiveCustomName,
|
title: effectiveCustomName,
|
||||||
author: mergedAuthor,
|
author: mergedAuthor,
|
||||||
rating: mergedRating
|
rating: mergedRating,
|
||||||
|
copyright: existing.copyright ?? null,
|
||||||
|
parentId: updatedRecord.parentFileId ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update suggestions cache for any metadata that was provided
|
// Update suggestions cache for any metadata that was provided
|
||||||
@@ -604,7 +816,9 @@ export class LibraryService {
|
|||||||
categories: updated.categories,
|
categories: updated.categories,
|
||||||
title: effectiveCustomName,
|
title: effectiveCustomName,
|
||||||
author: mergedAuthor,
|
author: mergedAuthor,
|
||||||
rating: mergedRating
|
rating: mergedRating,
|
||||||
|
copyright: existing.copyright ?? null,
|
||||||
|
parentId: updated.parentFileId ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update suggestions cache for any metadata that was provided
|
// Update suggestions cache for any metadata that was provided
|
||||||
@@ -636,6 +850,381 @@ export class LibraryService {
|
|||||||
this.search.rebuildIndex();
|
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.
|
* Returns the previously parsed UCS category catalog.
|
||||||
*/
|
*/
|
||||||
@@ -679,7 +1268,9 @@ export class LibraryService {
|
|||||||
categories: record.categories,
|
categories: record.categories,
|
||||||
title: record.customName ?? existing.title,
|
title: record.customName ?? existing.title,
|
||||||
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
|
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
|
// Write merged metadata to the WAV file
|
||||||
@@ -904,6 +1495,165 @@ export class LibraryService {
|
|||||||
return value.replace(/\\/g, '/');
|
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.
|
* Extracts metadata from the WAV container, falling back to defaults when parsing fails.
|
||||||
*/
|
*/
|
||||||
@@ -915,22 +1665,8 @@ export class LibraryService {
|
|||||||
categories: string[];
|
categories: string[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const mmImport = await import('music-metadata');
|
const parseFile = await this.resolveMusicMetadataParser();
|
||||||
const mm = (mmImport as { default?: unknown }).default ?? mmImport;
|
const metadata = await parseFile(filePath, { duration: true });
|
||||||
|
|
||||||
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 infoTags = this.tagService.readInfoTags(filePath);
|
const infoTags = this.tagService.readInfoTags(filePath);
|
||||||
const splitInfoValues = (input: string | undefined): string[] =>
|
const splitInfoValues = (input: string | undefined): string[] =>
|
||||||
input
|
input
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Fuse from 'fuse.js';
|
import Fuse, { type FuseResult, type IFuseOptions } from 'fuse.js';
|
||||||
import { AudioFileSummary } from '../../shared/models';
|
import { AudioFileSummary } from '../../shared/models';
|
||||||
import { DatabaseService } from './DatabaseService';
|
import { DatabaseService } from './DatabaseService';
|
||||||
import { TagService } from './TagService';
|
import { TagService } from './TagService';
|
||||||
@@ -76,7 +76,7 @@ export class SearchService {
|
|||||||
|
|
||||||
return fuseInstance
|
return fuseInstance
|
||||||
.search(remainingQuery)
|
.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)));
|
.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.
|
* Provides the standard Fuse configuration used by the service.
|
||||||
*/
|
*/
|
||||||
private createFuseOptions(): Fuse.IFuseOptions<AudioFileSummary> {
|
private createFuseOptions(): IFuseOptions<AudioFileSummary> {
|
||||||
return {
|
return {
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
threshold: 0.35,
|
threshold: 0.35,
|
||||||
@@ -114,7 +114,7 @@ export class SearchService {
|
|||||||
{ name: 'tags', weight: 0.2 },
|
{ name: 'tags', weight: 0.2 },
|
||||||
{ name: 'categories', weight: 0.1 }
|
{ name: 'categories', weight: 0.1 }
|
||||||
]
|
]
|
||||||
} satisfies Fuse.IFuseOptions<AudioFileSummary>;
|
} satisfies IFuseOptions<AudioFileSummary>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,15 +10,25 @@ export class TagService {
|
|||||||
public constructor(private readonly database: DatabaseService) {}
|
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 {
|
public applyTagging(fileId: number, tags: string[] | undefined, categories: string[]): AudioFileSummary {
|
||||||
const normalisedTags = this.normaliseValues(tags);
|
|
||||||
const normalisedCategories = this.normaliseValues(categories);
|
const normalisedCategories = this.normaliseValues(categories);
|
||||||
|
const normalisedTags = Array.isArray(tags) ? this.normaliseValues(tags) : undefined;
|
||||||
const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories);
|
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, {
|
this.writeWaveMetadata(updated.absolutePath, {
|
||||||
tags: normalisedTags,
|
tags: normalisedTags ?? updated.tags,
|
||||||
categories: normalisedCategories
|
categories: normalisedCategories,
|
||||||
|
title: existing.title,
|
||||||
|
author: existing.author,
|
||||||
|
rating: existing.rating,
|
||||||
|
copyright: existing.copyright,
|
||||||
|
parentId: existing.parentId ?? updated.parentFileId ?? null
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -44,21 +54,56 @@ export class TagService {
|
|||||||
author?: string;
|
author?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
|
copyright?: string;
|
||||||
|
parentId?: number;
|
||||||
} {
|
} {
|
||||||
try {
|
try {
|
||||||
const buffer = fs.readFileSync(filePath);
|
const buffer = fs.readFileSync(filePath);
|
||||||
const wave = new WaveFile(buffer);
|
const wave = new WaveFile(buffer);
|
||||||
const tags = this.extractInfoTagMap(wave);
|
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;
|
const ratingValue = tags.IRTD ? Number.parseInt(tags.IRTD, 10) : undefined;
|
||||||
let rating: number | undefined;
|
let rating: number | undefined;
|
||||||
if (typeof ratingValue === 'number' && Number.isFinite(ratingValue)) {
|
if (typeof ratingValue === 'number' && Number.isFinite(ratingValue)) {
|
||||||
rating = Math.floor(ratingValue / 2);
|
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 {
|
return {
|
||||||
author: tags.IART?.trim() || undefined,
|
author,
|
||||||
title: tags.INAM?.trim() || undefined,
|
title,
|
||||||
rating
|
rating,
|
||||||
|
copyright,
|
||||||
|
parentId
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
||||||
@@ -69,15 +114,18 @@ export class TagService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes tags and categories to an organized file as embedded metadata.
|
* Writes tags and categories to an organized file as embedded metadata.
|
||||||
|
* All metadata fields are required to prevent accidental clearing.
|
||||||
*/
|
*/
|
||||||
public writeMetadataOnly(
|
public writeMetadataOnly(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
metadata: {
|
metadata: {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
|
copyright?: string | null;
|
||||||
|
parentId?: number | null;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
this.writeWaveMetadata(filePath, metadata);
|
this.writeWaveMetadata(filePath, metadata);
|
||||||
@@ -85,15 +133,18 @@ export class TagService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes a simple INFO chunk with the provided metadata. Failures are swallowed so DB state remains authoritative.
|
* 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(
|
private writeWaveMetadata(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
metadata: {
|
metadata: {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
|
copyright?: string | null;
|
||||||
|
parentId?: number | null;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
@@ -104,22 +155,41 @@ export class TagService {
|
|||||||
listInfoTags?: Record<string, string>;
|
listInfoTags?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tagValuesList = metadata.tags.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
const tagValuesList = (metadata.tags ?? [])
|
||||||
const categoryValuesList = metadata.categories.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
.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 tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null;
|
||||||
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.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> = {
|
const tagValues: Record<string, string | null> = {
|
||||||
IKEY: tagText,
|
IKEY: tagText,
|
||||||
ICMT: tagText,
|
ICMT: commentText,
|
||||||
ISBJ: categoryText,
|
ISBJ: categoryText,
|
||||||
ISUB: primaryCategory,
|
ISUB: primaryCategory,
|
||||||
IGNR: null,
|
IGNR: null,
|
||||||
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
|
INAM: effectiveTitle,
|
||||||
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
|
IART: effectiveAuthor,
|
||||||
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
|
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
|
||||||
|
ICOP: effectiveCopyright,
|
||||||
|
IPAR: parentText,
|
||||||
ISFT: 'AudioSort'
|
ISFT: 'AudioSort'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,11 +199,13 @@ export class TagService {
|
|||||||
fs.writeFileSync(filePath, updatedBuffer);
|
fs.writeFileSync(filePath, updatedBuffer);
|
||||||
|
|
||||||
console.log(`Wrote metadata to ${filePath}:`, {
|
console.log(`Wrote metadata to ${filePath}:`, {
|
||||||
tags: metadata.tags.join(', '),
|
comment: commentText,
|
||||||
categories: metadata.categories.join(', '),
|
categories: metadata.categories.join(', '),
|
||||||
title: metadata.title,
|
title: effectiveTitle,
|
||||||
author: metadata.author,
|
author: effectiveAuthor,
|
||||||
rating: metadata.rating
|
copyright: effectiveCopyright,
|
||||||
|
rating: metadata.rating,
|
||||||
|
parentId: metadata.parentId ?? null
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
||||||
|
|||||||
10
src/main/types/standardized-audio-context.d.ts
vendored
Normal file
10
src/main/types/standardized-audio-context.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import type {
|
|||||||
AudioBufferPayload,
|
AudioBufferPayload,
|
||||||
AudioFileSummary,
|
AudioFileSummary,
|
||||||
CategoryRecord,
|
CategoryRecord,
|
||||||
|
LibraryImportResult,
|
||||||
LibraryScanSummary,
|
LibraryScanSummary,
|
||||||
|
SplitSegmentRequest,
|
||||||
TagUpdatePayload
|
TagUpdatePayload
|
||||||
} from '../shared/models';
|
} from '../shared/models';
|
||||||
|
|
||||||
@@ -25,9 +27,22 @@ const api: RendererApi = {
|
|||||||
async rescanLibrary(): Promise<LibraryScanSummary> {
|
async rescanLibrary(): Promise<LibraryScanSummary> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryScan);
|
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[]> {
|
async listAudioFiles(): Promise<AudioFileSummary[]> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryList);
|
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[] }[]> {
|
async listDuplicates(): Promise<{ checksum: string; files: AudioFileSummary[] }[]> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryDuplicates);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryDuplicates);
|
||||||
},
|
},
|
||||||
@@ -37,7 +52,10 @@ const api: RendererApi = {
|
|||||||
async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
|
async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryMove, fileId, targetRelativeDirectory);
|
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);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryOrganize, fileId, metadata);
|
||||||
},
|
},
|
||||||
async updateCustomName(fileId: number, customName: string | null): Promise<AudioFileSummary> {
|
async updateCustomName(fileId: number, customName: string | null): Promise<AudioFileSummary> {
|
||||||
@@ -49,6 +67,9 @@ const api: RendererApi = {
|
|||||||
async deleteFiles(fileIds: number[]): Promise<void> {
|
async deleteFiles(fileIds: number[]): Promise<void> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryDelete, fileIds);
|
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> {
|
async getAudioBuffer(fileId: number): Promise<AudioBufferPayload> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryBuffer, fileId);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryBuffer, fileId);
|
||||||
},
|
},
|
||||||
@@ -70,11 +91,14 @@ const api: RendererApi = {
|
|||||||
async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> {
|
async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryMetadataSuggestions);
|
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);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata);
|
||||||
},
|
},
|
||||||
onMenuAction(channel: string, callback: () => void): () => void {
|
onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void {
|
||||||
const listener = () => callback();
|
const listener = (_event: Electron.IpcRendererEvent, payload?: unknown) => callback(payload);
|
||||||
ipcRenderer.on(channel, listener);
|
ipcRenderer.on(channel, listener);
|
||||||
return () => ipcRenderer.removeListener(channel, listener);
|
return () => ipcRenderer.removeListener(channel, listener);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 FileList from './components/FileList';
|
||||||
import FileDetailPanel from './components/FileDetailPanel';
|
import FileDetailPanel from './components/FileDetailPanel';
|
||||||
import MultiFileEditor from './components/MultiFileEditor';
|
import MultiFileEditor from './components/MultiFileEditor';
|
||||||
@@ -6,10 +6,13 @@ import CategorySidebar from './components/CategorySidebar';
|
|||||||
import AudioPlayer from './components/AudioPlayer';
|
import AudioPlayer from './components/AudioPlayer';
|
||||||
import SettingsDialog from './components/SettingsDialog';
|
import SettingsDialog from './components/SettingsDialog';
|
||||||
import DuplicateComparisonDialog from './components/DuplicateComparisonDialog';
|
import DuplicateComparisonDialog from './components/DuplicateComparisonDialog';
|
||||||
|
import EditModePanel from './components/edit/EditModePanel';
|
||||||
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
|
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
|
||||||
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
|
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
|
||||||
import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore';
|
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.
|
* 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 [duplicateGroups, setDuplicateGroups] = useState<{ checksum: string; files: AudioFileSummary[] }[] | null>(null);
|
||||||
const [showStatusMessage, setShowStatusMessage] = useState(false);
|
const [showStatusMessage, setShowStatusMessage] = useState(false);
|
||||||
const [statusFadingOut, setStatusFadingOut] = useState(false);
|
const [statusFadingOut, setStatusFadingOut] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<RightPanelTab>('listen');
|
||||||
|
const [importMessage, setImportMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const statusMessage = useMemo(() => {
|
const statusMessage = useMemo(() => {
|
||||||
|
if (importMessage) {
|
||||||
|
return importMessage;
|
||||||
|
}
|
||||||
if (!library.lastScan) {
|
if (!library.lastScan) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { added, updated, removed } = library.lastScan;
|
const { added, updated, removed } = library.lastScan;
|
||||||
return `Scan complete: +${added} updated ${updated} removed ${removed}`;
|
return `Scan complete: +${added} updated ${updated} removed ${removed}`;
|
||||||
}, [library.lastScan]);
|
}, [importMessage, library.lastScan]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusMessage) {
|
if (statusMessage) {
|
||||||
@@ -40,6 +48,7 @@ function App(): JSX.Element {
|
|||||||
const hideTimer = setTimeout(() => {
|
const hideTimer = setTimeout(() => {
|
||||||
setShowStatusMessage(false);
|
setShowStatusMessage(false);
|
||||||
setStatusFadingOut(false);
|
setStatusFadingOut(false);
|
||||||
|
setImportMessage((current) => (current === statusMessage ? null : current));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(fadeTimer);
|
clearTimeout(fadeTimer);
|
||||||
@@ -48,23 +57,52 @@ function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [statusMessage]);
|
}, [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(
|
const selectedFile = useMemo(
|
||||||
() => library.files.find((file) => file.id === library.selectedFileId) ?? null,
|
() => library.files.find((file) => file.id === library.selectedFileId) ?? null,
|
||||||
[library.files, library.selectedFileId]
|
[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(
|
const selectedFiles = useMemo(
|
||||||
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
|
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
|
||||||
[library.files, library.selectedFileIds]
|
[library.files, library.selectedFileIds]
|
||||||
@@ -110,14 +148,82 @@ function App(): JSX.Element {
|
|||||||
void libraryStore.search(value);
|
void libraryStore.search(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRescan = async () => {
|
const handleRescan = useCallback(async () => {
|
||||||
await libraryStore.rescan();
|
await libraryStore.rescan();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleFindDuplicates = async () => {
|
const handleFindDuplicates = useCallback(async () => {
|
||||||
const duplicates = await window.api.listDuplicates();
|
const duplicates = await window.api.listDuplicates();
|
||||||
setDuplicateGroups(duplicates);
|
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[]) => {
|
const handleKeepDuplicate = async (fileIdToKeep: number, fileIdsToDelete: number[]) => {
|
||||||
await window.api.deleteFiles(fileIdsToDelete);
|
await window.api.deleteFiles(fileIdsToDelete);
|
||||||
@@ -132,10 +238,6 @@ function App(): JSX.Element {
|
|||||||
libraryStore.selectFile(fileId, options);
|
libraryStore.selectFile(fileId, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAllFiles = () => {
|
|
||||||
libraryStore.selectAllVisibleFiles();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayFile = (file: AudioFileSummary) => {
|
const handlePlayFile = (file: AudioFileSummary) => {
|
||||||
loadPlayerFile(file, true);
|
loadPlayerFile(file, true);
|
||||||
};
|
};
|
||||||
@@ -158,11 +260,19 @@ function App(): JSX.Element {
|
|||||||
await libraryStore.moveFile(selectedFile.id, targetDirectory);
|
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) {
|
if (!selectedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await libraryStore.organizeFile(selectedFile.id, metadata);
|
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) => {
|
const handleUpdateCustomName = async (customName: string | null) => {
|
||||||
@@ -172,26 +282,31 @@ function App(): JSX.Element {
|
|||||||
await libraryStore.updateCustomName(selectedFile.id, customName);
|
await libraryStore.updateCustomName(selectedFile.id, customName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTagUpdate = async (data: { tags: string[]; categories: string[] }) => {
|
const handleTagUpdate = async (categories: string[]) => {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
return;
|
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[] }) => {
|
const handleOpenParent = (parentId: number) => {
|
||||||
await libraryStore.updateTagging({ fileId, ...data });
|
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) => {
|
const handleMultiFileCustomName = async (fileId: number, customName: string | null) => {
|
||||||
await libraryStore.updateCustomName(fileId, customName);
|
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);
|
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);
|
await libraryStore.updateFileMetadata(fileId, metadata);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,10 +319,9 @@ function App(): JSX.Element {
|
|||||||
if (existingCategories.includes(categoryId)) continue;
|
if (existingCategories.includes(categoryId)) continue;
|
||||||
|
|
||||||
const newCategories = [...existingCategories, categoryId];
|
const newCategories = [...existingCategories, categoryId];
|
||||||
await libraryStore.updateTagging({
|
await libraryStore.updateTagging({
|
||||||
fileId,
|
fileId,
|
||||||
tags: file.tags,
|
categories: newCategories
|
||||||
categories: newCategories
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -217,6 +331,17 @@ function App(): JSX.Element {
|
|||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditModeClose = () => {
|
||||||
|
setActiveTab('listen');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditModeSplitComplete = (created: AudioFileSummary[]) => {
|
||||||
|
setActiveTab('listen');
|
||||||
|
if (created.length > 0) {
|
||||||
|
libraryStore.selectFile(created[0].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
{showStatusMessage && statusMessage && (
|
{showStatusMessage && statusMessage && (
|
||||||
@@ -229,6 +354,7 @@ function App(): JSX.Element {
|
|||||||
categories={library.categories}
|
categories={library.categories}
|
||||||
files={library.files}
|
files={library.files}
|
||||||
activeFilter={library.categoryFilter}
|
activeFilter={library.categoryFilter}
|
||||||
|
justSplitIds={library.justSplitFileIds}
|
||||||
onSelect={handleCategorySelect}
|
onSelect={handleCategorySelect}
|
||||||
onDropFiles={handleDropFilesToCategory}
|
onDropFiles={handleDropFilesToCategory}
|
||||||
/>
|
/>
|
||||||
@@ -240,7 +366,6 @@ function App(): JSX.Element {
|
|||||||
onPlay={handlePlayFile}
|
onPlay={handlePlayFile}
|
||||||
searchValue={library.searchQuery}
|
searchValue={library.searchQuery}
|
||||||
onSearchChange={handleSearch}
|
onSearchChange={handleSearch}
|
||||||
onSelectAll={handleSelectAllFiles}
|
|
||||||
/>
|
/>
|
||||||
<div className="app-details">
|
<div className="app-details">
|
||||||
{isMultiSelect ? (
|
{isMultiSelect ? (
|
||||||
@@ -255,17 +380,47 @@ function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FileDetailPanel
|
<div className="detail-tabs">
|
||||||
file={selectedFile}
|
<button
|
||||||
categories={library.categories}
|
type="button"
|
||||||
onRename={handleRename}
|
className={activeTab === 'listen' ? 'detail-tab detail-tab--active' : 'detail-tab'}
|
||||||
onMove={handleMove}
|
onClick={() => setActiveTab('listen')}
|
||||||
onOrganize={handleOrganize}
|
>
|
||||||
onUpdateTags={handleTagUpdate}
|
Listen
|
||||||
onUpdateCustomName={handleUpdateCustomName}
|
</button>
|
||||||
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
|
<button
|
||||||
/>
|
type="button"
|
||||||
<AudioPlayer snapshot={player} />
|
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>
|
</div>
|
||||||
|
|||||||
BIN
src/renderer/src/assets/icon.ico
Normal file
BIN
src/renderer/src/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
BIN
src/renderer/src/assets/icon.png
Normal file
BIN
src/renderer/src/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
0
src/renderer/src/audio/scrubWorklet.ts
Normal file
0
src/renderer/src/audio/scrubWorklet.ts
Normal file
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
|
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 {
|
import {
|
||||||
buildCategorySwatch,
|
buildCategorySwatch,
|
||||||
createCategoryStyleVars,
|
createCategoryStyleVars,
|
||||||
@@ -11,6 +11,8 @@ export interface CategorySidebarProps {
|
|||||||
categories: CategoryRecord[];
|
categories: CategoryRecord[];
|
||||||
files: AudioFileSummary[];
|
files: AudioFileSummary[];
|
||||||
activeFilter: CategoryFilterValue;
|
activeFilter: CategoryFilterValue;
|
||||||
|
/** Identifiers of files created during the most recent split action. */
|
||||||
|
justSplitIds: number[];
|
||||||
onSelect(filter: CategoryFilterValue): void;
|
onSelect(filter: CategoryFilterValue): void;
|
||||||
onDropFiles?(fileIds: number[], categoryId: string): 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 { total, untagged, categoryCounts } = deriveCounts(files);
|
||||||
const swatchMap = useMemo(() => {
|
const swatchMap = useMemo(() => {
|
||||||
const map = new Map<string, ReturnType<typeof buildCategorySwatch>>();
|
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__label">TO TAG</span>
|
||||||
<span className="category-sidebar__count">{untagged}</span>
|
<span className="category-sidebar__count">{untagged}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{sortedGroups.map(([groupName, records]) => {
|
{sortedGroups.map(([groupName, records]) => {
|
||||||
const sortedRecords = records
|
const sortedRecords = records
|
||||||
|
|||||||
78
src/renderer/src/components/DrivePickerDialog.tsx
Normal file
78
src/renderer/src/components/DrivePickerDialog.tsx
Normal 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;
|
||||||
@@ -2,25 +2,36 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
|||||||
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
|
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
|
||||||
import { TagEditor } from './TagEditor';
|
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.
|
* Props for FileDetailPanel component.
|
||||||
*/
|
*/
|
||||||
export interface FileDetailPanelProps {
|
export interface FileDetailPanelProps {
|
||||||
file: AudioFileSummary | null;
|
file: AudioFileSummary | null;
|
||||||
|
/** Optional summary of the parent file when this entry was generated from another file. */
|
||||||
|
parentFile: AudioFileSummary | null;
|
||||||
categories: CategoryRecord[];
|
categories: CategoryRecord[];
|
||||||
onRename(newName: string): Promise<void>;
|
onRename(newName: string): Promise<void>;
|
||||||
onMove(targetRelativeDirectory: string): Promise<void>;
|
onMove(targetRelativeDirectory: string): Promise<void>;
|
||||||
/** Organizes a file with optional metadata fields (customName, author, rating 1-5). */
|
/** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */
|
||||||
onOrganize(metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
|
onOrganize(metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise<void>;
|
||||||
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
|
onUpdateTags(categories: string[]): Promise<void>;
|
||||||
onUpdateCustomName(customName: string | null): Promise<void>;
|
onUpdateCustomName(customName: string | null): Promise<void>;
|
||||||
|
/** Invoked when the user requests to open the parent file. */
|
||||||
|
onOpenParent?(parentId: number): void;
|
||||||
metadataSuggestionsVersion: number;
|
metadataSuggestionsVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays metadata for the selected file with rename, move, and tagging controls.
|
* 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 [isEditingCustomName, setIsEditingCustomName] = useState(false);
|
||||||
const [moveDraft, setMoveDraft] = useState('');
|
const [moveDraft, setMoveDraft] = useState('');
|
||||||
const [customNameDraft, setCustomNameDraft] = useState('');
|
const [customNameDraft, setCustomNameDraft] = useState('');
|
||||||
@@ -29,8 +40,9 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
||||||
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
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: '',
|
author: '',
|
||||||
|
copyright: '',
|
||||||
rating: 0,
|
rating: 0,
|
||||||
customName: ''
|
customName: ''
|
||||||
});
|
});
|
||||||
@@ -93,7 +105,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setAuthorDraft('');
|
setAuthorDraft('');
|
||||||
setRatingDraft(0);
|
setRatingDraft(0);
|
||||||
setIsEditingCustomName(false);
|
setIsEditingCustomName(false);
|
||||||
initialMetadataRef.current = { author: '', rating: 0, customName: '' };
|
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: '' };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +113,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setCustomNameDraft(file.customName ?? '');
|
setCustomNameDraft(file.customName ?? '');
|
||||||
setIsEditingCustomName(false);
|
setIsEditingCustomName(false);
|
||||||
const baseCustomName = (file.customName ?? '').trim();
|
const baseCustomName = (file.customName ?? '').trim();
|
||||||
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
|
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +129,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setRatingDraft(ratingValue);
|
setRatingDraft(ratingValue);
|
||||||
initialMetadataRef.current = {
|
initialMetadataRef.current = {
|
||||||
author: authorValue,
|
author: authorValue,
|
||||||
|
copyright: '',
|
||||||
rating: ratingValue,
|
rating: ratingValue,
|
||||||
customName: customNameValue
|
customName: customNameValue
|
||||||
};
|
};
|
||||||
@@ -127,7 +140,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
console.error('Failed to read file metadata:', error);
|
console.error('Failed to read file metadata:', error);
|
||||||
setAuthorDraft('');
|
setAuthorDraft('');
|
||||||
setRatingDraft(0);
|
setRatingDraft(0);
|
||||||
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
|
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [appendSuggestion, file?.id]);
|
}, [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) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,18 +213,21 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
const nextCustomNameRaw =
|
const nextCustomNameRaw =
|
||||||
overrides?.customName !== undefined ? overrides.customName ?? '' : customNameDraft;
|
overrides?.customName !== undefined ? overrides.customName ?? '' : customNameDraft;
|
||||||
|
|
||||||
const trimmedAuthor = (nextAuthorRaw ?? '').trim();
|
const trimmedAuthor = nextAuthorRaw.trim();
|
||||||
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
|
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
|
||||||
const comparisonState = {
|
const comparisonState = {
|
||||||
author: trimmedAuthor,
|
author: trimmedAuthor,
|
||||||
|
copyright: '',
|
||||||
rating: nextRatingValue,
|
rating: nextRatingValue,
|
||||||
customName: trimmedCustomName
|
customName: trimmedCustomName
|
||||||
};
|
};
|
||||||
|
|
||||||
const previous = initialMetadataRef.current;
|
const previous = initialMetadataRef.current;
|
||||||
if (
|
if (
|
||||||
|
!force &&
|
||||||
previous &&
|
previous &&
|
||||||
previous.author === comparisonState.author &&
|
previous.author === comparisonState.author &&
|
||||||
|
previous.copyright === comparisonState.copyright &&
|
||||||
previous.rating === comparisonState.rating &&
|
previous.rating === comparisonState.rating &&
|
||||||
previous.customName === comparisonState.customName
|
previous.customName === comparisonState.customName
|
||||||
) {
|
) {
|
||||||
@@ -220,18 +236,17 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const authorPayload = trimmedAuthor.length > 0 ? trimmedAuthor : null;
|
|
||||||
if (file.categories.length === 0) {
|
if (file.categories.length === 0) {
|
||||||
// No categories - just save metadata without organizing
|
// No categories - just save metadata without organizing
|
||||||
await window.api.updateFileMetadata(file.id, {
|
await window.api.updateFileMetadata(file.id, {
|
||||||
author: authorPayload,
|
author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined,
|
||||||
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Has categories - organize the file
|
// Has categories - organize the file
|
||||||
await onOrganize({
|
await onOrganize({
|
||||||
customName: trimmedCustomName.length > 0 ? trimmedCustomName : null,
|
customName: trimmedCustomName.length > 0 ? trimmedCustomName : undefined,
|
||||||
author: authorPayload,
|
author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined,
|
||||||
rating: comparisonState.rating > 0 ? comparisonState.rating : 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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await onUpdateTags(data);
|
await onUpdateTags(selectedCategories);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update categories', error);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
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 = () => {
|
const toggleTagSection = () => {
|
||||||
setIsTagSectionExpanded((value) => !value);
|
setIsTagSectionExpanded((value) => !value);
|
||||||
};
|
};
|
||||||
@@ -289,15 +313,65 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
{file.customName || file.displayName}
|
{file.customName || file.displayName}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
<p
|
<div
|
||||||
onClick={handleOpenFolder}
|
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||||
style={{ cursor: 'pointer', opacity: 0.7, transition: 'opacity 0.2s ease' }}
|
onMouseEnter={(e) => {
|
||||||
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
|
const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement;
|
||||||
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.7'}
|
if (btn) btn.style.opacity = '1';
|
||||||
title="Click to open in folder"
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement;
|
||||||
|
if (btn) btn.style.opacity = '0';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{file.relativePath}
|
<p
|
||||||
</p>
|
onClick={handleOpenFolder}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
<div className="file-meta-grid">
|
<div className="file-meta-grid">
|
||||||
<div>{formatBytes(file.size)}</div>
|
<div>{formatBytes(file.size)}</div>
|
||||||
@@ -371,17 +445,16 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
onClick={toggleTagSection}
|
onClick={toggleTagSection}
|
||||||
aria-expanded={isTagSectionExpanded}
|
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">
|
<span className="tag-editor-toggle-icon" aria-hidden="true">
|
||||||
{isTagSectionExpanded ? 'v' : '>'}
|
{isTagSectionExpanded ? 'v' : '>'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
|
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
|
||||||
<TagEditor
|
<TagEditor
|
||||||
tags={file.tags}
|
|
||||||
categories={file.categories}
|
categories={file.categories}
|
||||||
availableCategories={categories}
|
availableCategories={categories}
|
||||||
onSave={handleTagSave}
|
onSave={handleCategorySave}
|
||||||
showHeading={false}
|
showHeading={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,26 +9,21 @@ export interface FileListProps {
|
|||||||
onPlay?(file: AudioFileSummary): void;
|
onPlay?(file: AudioFileSummary): void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onSearchChange(value: string): void;
|
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.
|
* 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 buttonRefs = useRef(new Map<number, HTMLButtonElement>());
|
||||||
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
|
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
|
||||||
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
|
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
|
||||||
const dragGhostRef = useRef<HTMLElement | null>(null);
|
const dragGhostRef = useRef<HTMLElement | null>(null);
|
||||||
const dragRotation = useRef(0);
|
const dragRotation = useRef<number>(0);
|
||||||
const lastDragX = useRef(0);
|
const lastDragX = useRef<number>(0);
|
||||||
const dropTargetPosition = useRef<{ x: number; y: number } | null>(null);
|
const dropTargetPosition = useRef<{ x: number; y: number } | null>(null);
|
||||||
const dragOffset = useRef({ x: 0, y: 0 });
|
const dragOffset = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
const dragStartTime = useRef(0);
|
const dragStartTime = useRef<number>(0);
|
||||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId === null) {
|
if (selectedId === null) {
|
||||||
@@ -44,6 +39,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
if (isTypingContext || isEditable) {
|
if (isTypingContext || isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep keyboard focus aligned with the active selection.
|
||||||
button.focus();
|
button.focus();
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
@@ -92,43 +88,6 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
};
|
};
|
||||||
}, [files]);
|
}, [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) => {
|
const handleClick = (fileId: number, event: React.MouseEvent) => {
|
||||||
onSelect(fileId, {
|
onSelect(fileId, {
|
||||||
multi: event.ctrlKey || event.metaKey,
|
multi: event.ctrlKey || event.metaKey,
|
||||||
@@ -166,7 +125,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="file-list-items" ref={listContainerRef}>
|
<div className="file-list-items">
|
||||||
{files.map((file) => {
|
{files.map((file) => {
|
||||||
const isActive = file.id === selectedId;
|
const isActive = file.id === selectedId;
|
||||||
const isSelected = selectedIds.has(file.id);
|
const isSelected = selectedIds.has(file.id);
|
||||||
@@ -186,6 +145,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
type="button"
|
type="button"
|
||||||
className={className}
|
className={className}
|
||||||
draggable={true}
|
draggable={true}
|
||||||
|
data-file-id={file.id}
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (node) {
|
if (node) {
|
||||||
buttonRefs.current.set(file.id, 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)}
|
onClick={(event) => handleClick(file.id, event)}
|
||||||
onDoubleClick={() => handleDoubleClick(file)}
|
onDoubleClick={() => handleDoubleClick(file)}
|
||||||
onDragStart={(event) => {
|
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];
|
const draggedIds = selectedIds.has(file.id) ? Array.from(selectedIds) : [file.id];
|
||||||
|
|
||||||
event.dataTransfer.setData('application/audiosort-file', JSON.stringify({
|
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';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
|
||||||
|
// Create transparent drag image to hide the default
|
||||||
const transparent = document.createElement('div');
|
const transparent = document.createElement('div');
|
||||||
transparent.style.width = '1px';
|
transparent.style.width = '1px';
|
||||||
transparent.style.height = '1px';
|
transparent.style.height = '1px';
|
||||||
@@ -212,6 +175,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
event.dataTransfer.setDragImage(transparent, 0, 0);
|
event.dataTransfer.setDragImage(transparent, 0, 0);
|
||||||
setTimeout(() => transparent.remove(), 0);
|
setTimeout(() => transparent.remove(), 0);
|
||||||
|
|
||||||
|
// Calculate offset from click position to element center
|
||||||
const sourceElement = event.currentTarget as HTMLElement;
|
const sourceElement = event.currentTarget as HTMLElement;
|
||||||
const rect = sourceElement.getBoundingClientRect();
|
const rect = sourceElement.getBoundingClientRect();
|
||||||
const elementCenterX = rect.left + rect.width / 2;
|
const elementCenterX = rect.left + rect.width / 2;
|
||||||
@@ -221,18 +185,21 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
y: elementCenterY - event.clientY
|
y: elementCenterY - event.clientY
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate transition duration based on offset distance
|
||||||
const offsetDistance = Math.sqrt(
|
const offsetDistance = Math.sqrt(
|
||||||
dragOffset.current.x * dragOffset.current.x +
|
dragOffset.current.x * dragOffset.current.x +
|
||||||
dragOffset.current.y * dragOffset.current.y
|
dragOffset.current.y * dragOffset.current.y
|
||||||
);
|
);
|
||||||
const maxDistance = Math.sqrt(rect.width * rect.width + rect.height * rect.height) / 2;
|
const maxDistance = Math.sqrt(rect.width * rect.width + rect.height * rect.height) / 2;
|
||||||
const normalizedDistance = Math.min(offsetDistance / maxDistance, 1);
|
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();
|
dragStartTime.current = Date.now();
|
||||||
|
|
||||||
|
// Clone the current element for custom floating preview
|
||||||
const ghost = sourceElement.cloneNode(true) as HTMLElement;
|
const ghost = sourceElement.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
|
// Create a wrapper for rotation that doesn't affect position transition
|
||||||
const rotationWrapper = document.createElement('div');
|
const rotationWrapper = document.createElement('div');
|
||||||
rotationWrapper.style.position = 'fixed';
|
rotationWrapper.style.position = 'fixed';
|
||||||
rotationWrapper.style.top = `${event.clientY}px`;
|
rotationWrapper.style.top = `${event.clientY}px`;
|
||||||
@@ -256,6 +223,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
rotationWrapper.appendChild(ghost);
|
rotationWrapper.appendChild(ghost);
|
||||||
document.body.appendChild(rotationWrapper);
|
document.body.appendChild(rotationWrapper);
|
||||||
|
|
||||||
|
// Trigger the offset transition to center
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (ghost.parentNode) {
|
if (ghost.parentNode) {
|
||||||
ghost.style.transform = `translate(-50%, -50%)`;
|
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));
|
const clamped = Math.max(-12, Math.min(12, blended));
|
||||||
dragRotation.current = clamped;
|
dragRotation.current = clamped;
|
||||||
|
|
||||||
|
// Update wrapper position (no rotation here)
|
||||||
dragGhostRef.current.style.top = `${event.clientY}px`;
|
dragGhostRef.current.style.top = `${event.clientY}px`;
|
||||||
dragGhostRef.current.style.left = `${event.clientX}px`;
|
dragGhostRef.current.style.left = `${event.clientX}px`;
|
||||||
|
|
||||||
|
// Apply rotation directly to wrapper without transition
|
||||||
dragGhostRef.current.style.transform = `rotate(${clamped}deg)`;
|
dragGhostRef.current.style.transform = `rotate(${clamped}deg)`;
|
||||||
|
|
||||||
|
// Track position for potential drop animation
|
||||||
dropTargetPosition.current = { x: event.clientX, y: event.clientY };
|
dropTargetPosition.current = { x: event.clientX, y: event.clientY };
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const wrapper = dragGhostRef.current;
|
const wrapper = dragGhostRef.current;
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
// Check if this was a successful drop (dropEffect will be 'copy' if accepted)
|
||||||
const wasDropped = event.dataTransfer.dropEffect === 'copy';
|
const wasDropped = event.dataTransfer.dropEffect === 'copy';
|
||||||
|
|
||||||
if (wasDropped && dropTargetPosition.current) {
|
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.transition = 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||||
wrapper.style.transform = `rotate(0deg) scale(0)`;
|
wrapper.style.transform = `rotate(0deg) scale(0)`;
|
||||||
wrapper.style.opacity = '0';
|
wrapper.style.opacity = '0';
|
||||||
@@ -299,6 +273,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
dragGhostRef.current = null;
|
dragGhostRef.current = null;
|
||||||
}, 300);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
|
// No drop or cancelled - remove immediately
|
||||||
wrapper.remove();
|
wrapper.remove();
|
||||||
dragGhostRef.current = null;
|
dragGhostRef.current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { TagEditor } from './TagEditor';
|
|||||||
export interface MultiFileEditorProps {
|
export interface MultiFileEditorProps {
|
||||||
files: AudioFileSummary[];
|
files: AudioFileSummary[];
|
||||||
categories: CategoryRecord[];
|
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>;
|
onUpdateCustomName(fileId: number, customName: string | null): Promise<void>;
|
||||||
onOrganize(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): 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>;
|
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 {
|
export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCustomName, onOrganize, onUpdateMetadata, metadataSuggestionsVersion }: MultiFileEditorProps): JSX.Element {
|
||||||
const [sharedCustomName, setSharedCustomName] = useState('');
|
const [sharedCustomName, setSharedCustomName] = useState('');
|
||||||
@@ -23,20 +23,6 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
|
|||||||
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
||||||
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
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 aggregatedCategories = useMemo(() => {
|
||||||
const categoryCounts = new Map<string, number>();
|
const categoryCounts = new Map<string, number>();
|
||||||
for (const file of files) {
|
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 {
|
try {
|
||||||
let failureCount = 0;
|
let failureCount = 0;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
await onUpdateTags(file.id, data);
|
await onUpdateTags(file.id, selectedCategories);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failureCount += 1;
|
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) {
|
if (failureCount > 0) {
|
||||||
console.error(`${failureCount} file(s) failed to update tags`);
|
console.error(`${failureCount} file(s) failed to update categories`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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}
|
onClick={toggleTagSection}
|
||||||
aria-expanded={isTagSectionExpanded}
|
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">
|
<span className="tag-editor-toggle-icon" aria-hidden="true">
|
||||||
{isTagSectionExpanded ? 'v' : '>'}
|
{isTagSectionExpanded ? 'v' : '>'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
|
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
|
||||||
<TagEditor
|
<TagEditor
|
||||||
tags={aggregatedTags}
|
|
||||||
categories={aggregatedCategories}
|
categories={aggregatedCategories}
|
||||||
availableCategories={categories}
|
availableCategories={categories}
|
||||||
onSave={handleTagSave}
|
onSave={handleCategorySave}
|
||||||
showHeading={false}
|
showHeading={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import {
|
import { useEffect, useMemo, useState, useRef, useLayoutEffect, type UIEvent } from 'react';
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
useLayoutEffect,
|
|
||||||
type ChangeEvent,
|
|
||||||
type UIEvent,
|
|
||||||
type CSSProperties
|
|
||||||
} from 'react';
|
|
||||||
import type { CategoryRecord } from '../../../shared/models';
|
import type { CategoryRecord } from '../../../shared/models';
|
||||||
import {
|
import {
|
||||||
getCollapsedGroups,
|
getCollapsedGroups,
|
||||||
@@ -24,19 +15,17 @@ import {
|
|||||||
import type { CategorySwatch } from '../utils/categoryColors';
|
import type { CategorySwatch } from '../utils/categoryColors';
|
||||||
|
|
||||||
export interface TagEditorProps {
|
export interface TagEditorProps {
|
||||||
tags: string[];
|
|
||||||
categories: string[];
|
categories: string[];
|
||||||
availableCategories: CategoryRecord[];
|
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. */
|
/** If false the internal heading is omitted. Defaults to true. */
|
||||||
showHeading?: boolean;
|
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 {
|
export function TagEditor({ categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element {
|
||||||
const [tagDraft, setTagDraft] = useState(tags.join(', '));
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState('');
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
const [selectedCategories, setSelectedCategories] = useState(new Set(categories));
|
const [selectedCategories, setSelectedCategories] = useState(new Set(categories));
|
||||||
const [isCategoryListExpanded, setIsCategoryListExpanded] = useState(false);
|
const [isCategoryListExpanded, setIsCategoryListExpanded] = useState(false);
|
||||||
@@ -52,7 +41,6 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
|
|||||||
const isFirstRender = useRef(true);
|
const isFirstRender = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTagDraft(tags.join(', '));
|
|
||||||
setSelectedCategories(new Set(categories));
|
setSelectedCategories(new Set(categories));
|
||||||
|
|
||||||
if (!isFirstRender.current) {
|
if (!isFirstRender.current) {
|
||||||
@@ -61,7 +49,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
|
|||||||
} else {
|
} else {
|
||||||
isFirstRender.current = false;
|
isFirstRender.current = false;
|
||||||
}
|
}
|
||||||
}, [tags, categories]);
|
}, [categories]);
|
||||||
|
|
||||||
const { groupedCategories, filteredResults } = useMemo(() => {
|
const { groupedCategories, filteredResults } = useMemo(() => {
|
||||||
const filter = categoryFilter.trim().toLowerCase();
|
const filter = categoryFilter.trim().toLowerCase();
|
||||||
@@ -83,16 +71,18 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
|
|||||||
}, [availableCategories, categoryFilter]);
|
}, [availableCategories, categoryFilter]);
|
||||||
|
|
||||||
const selectedCategoryRecords = useMemo(
|
const selectedCategoryRecords = useMemo(
|
||||||
() => availableCategories
|
() => {
|
||||||
.filter((entry) => selectedCategories.has(entry.id))
|
// Preserve the order from the categories prop (first category is primary)
|
||||||
.sort((left, right) => {
|
const orderedRecords: CategoryRecord[] = [];
|
||||||
const topLevelComparison = left.category.localeCompare(right.category);
|
for (const catId of categories) {
|
||||||
if (topLevelComparison !== 0) {
|
const record = availableCategories.find((entry) => entry.id === catId);
|
||||||
return topLevelComparison;
|
if (record && selectedCategories.has(catId)) {
|
||||||
|
orderedRecords.push(record);
|
||||||
}
|
}
|
||||||
return left.subCategory.localeCompare(right.subCategory);
|
}
|
||||||
}),
|
return orderedRecords;
|
||||||
[availableCategories, selectedCategories]
|
},
|
||||||
|
[availableCategories, selectedCategories, categories]
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryColorMap = useMemo(() => {
|
const categoryColorMap = useMemo(() => {
|
||||||
@@ -146,20 +136,17 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
|
|||||||
next.add(categoryId);
|
next.add(categoryId);
|
||||||
}
|
}
|
||||||
setSelectedCategories(next);
|
setSelectedCategories(next);
|
||||||
|
onSave(Array.from(next));
|
||||||
const parsedTags = tagDraft
|
|
||||||
.split(',')
|
|
||||||
.map((value: string) => value.trim())
|
|
||||||
.filter((value: string) => value.length > 0);
|
|
||||||
onSave({ tags: parsedTags, categories: Array.from(next) });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTagBlur = () => {
|
const handleSetPrimaryCategory = (categoryId: string) => {
|
||||||
const parsedTags = tagDraft
|
const currentList = Array.from(selectedCategories);
|
||||||
.split(',')
|
// Remove the category from its current position
|
||||||
.map((value: string) => value.trim())
|
const filteredList = currentList.filter((id) => id !== categoryId);
|
||||||
.filter((value: string) => value.length > 0);
|
// Add it to the front
|
||||||
onSave({ tags: parsedTags, categories: Array.from(selectedCategories) });
|
const reorderedList = [categoryId, ...filteredList];
|
||||||
|
setSelectedCategories(new Set(reorderedList));
|
||||||
|
void onSave(reorderedList);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveCategory = (categoryId: string) => {
|
const handleRemoveCategory = (categoryId: string) => {
|
||||||
@@ -174,11 +161,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedCategories(new Set());
|
setSelectedCategories(new Set());
|
||||||
const parsedTags = tagDraft
|
onSave([]);
|
||||||
.split(',')
|
|
||||||
.map((value: string) => value.trim())
|
|
||||||
.filter((value: string) => value.length > 0);
|
|
||||||
onSave({ tags: parsedTags, categories: [] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategoryList = () => {
|
const toggleCategoryList = () => {
|
||||||
@@ -187,18 +170,127 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="tag-editor">
|
<section className="tag-editor">
|
||||||
{showHeading && <h2>Tags</h2>}
|
{showHeading && <h2>Categories</h2>}
|
||||||
<textarea
|
<div className="selected-categories">
|
||||||
value={tagDraft}
|
<div className="selected-category-chip-list">
|
||||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setTagDraft(event.target.value)}
|
{selectedCategoryRecords.length === 0 && <span className="selected-category-empty">No categories selected</span>}
|
||||||
onBlur={handleTagBlur}
|
{selectedCategoryRecords.map((entry, index) => {
|
||||||
placeholder="Comma separated tags"
|
const categoryLabel = formatCategoryLabel(entry.category);
|
||||||
rows={3}
|
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">
|
<div className="category-filter">
|
||||||
<input
|
<input
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setCategoryFilter(event.target.value)}
|
onChange={(event) => setCategoryFilter(event.target.value)}
|
||||||
placeholder="Filter UCS categories"
|
placeholder="Filter UCS categories"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="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">
|
<div className="category-list-pane">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
879
src/renderer/src/components/edit/EditModePanel.tsx
Normal file
879
src/renderer/src/components/edit/EditModePanel.tsx
Normal 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;
|
||||||
1120
src/renderer/src/components/edit/WaveformEditorCanvas.tsx
Normal file
1120
src/renderer/src/components/edit/WaveformEditorCanvas.tsx
Normal file
File diff suppressed because it is too large
Load Diff
68
src/renderer/src/components/edit/types.ts
Normal file
68
src/renderer/src/components/edit/types.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,14 +2,18 @@ import type {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
AudioFileSummary,
|
AudioFileSummary,
|
||||||
CategoryRecord,
|
CategoryRecord,
|
||||||
|
LibraryImportResult,
|
||||||
LibraryScanSummary,
|
LibraryScanSummary,
|
||||||
|
SplitSegmentRequest,
|
||||||
TagUpdatePayload
|
TagUpdatePayload
|
||||||
} from '../../../shared/models';
|
} from '../../../shared/models';
|
||||||
|
|
||||||
type ChangeListener = () => void;
|
type ChangeListener = () => void;
|
||||||
|
|
||||||
export const CATEGORY_FILTER_UNTAGGED = 'untagged' as const;
|
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 {
|
export interface LibrarySnapshot {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
@@ -24,6 +28,8 @@ export interface LibrarySnapshot {
|
|||||||
categoryFilter: CategoryFilterValue;
|
categoryFilter: CategoryFilterValue;
|
||||||
lastScan: LibraryScanSummary | null;
|
lastScan: LibraryScanSummary | null;
|
||||||
metadataSuggestionsVersion: number;
|
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: '',
|
searchQuery: '',
|
||||||
categoryFilter: null,
|
categoryFilter: null,
|
||||||
lastScan: null,
|
lastScan: null,
|
||||||
metadataSuggestionsVersion: 0
|
metadataSuggestionsVersion: 0,
|
||||||
|
justSplitFileIds: []
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,8 +61,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
window.api.listCategories(),
|
window.api.listCategories(),
|
||||||
window.api.listAudioFiles()
|
window.api.listAudioFiles()
|
||||||
]);
|
]);
|
||||||
const firstFileId = files.at(0)?.id ?? null;
|
const firstFileId = files[0]?.id ?? null;
|
||||||
const firstFile = files.at(0) ?? null;
|
const firstFile = files[0] ?? null;
|
||||||
const nextVersion = this.snapshot.metadataSuggestionsVersion + 1;
|
const nextVersion = this.snapshot.metadataSuggestionsVersion + 1;
|
||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
@@ -69,7 +76,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
categoryFilter: null,
|
categoryFilter: null,
|
||||||
lastScan: null,
|
lastScan: null,
|
||||||
metadataSuggestionsVersion: nextVersion
|
metadataSuggestionsVersion: nextVersion,
|
||||||
|
justSplitFileIds: []
|
||||||
};
|
};
|
||||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||||
}
|
}
|
||||||
@@ -206,6 +214,36 @@ export class LibraryStore extends EventTarget {
|
|||||||
this.emitChange();
|
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.
|
* Executes a fuzzy search request.
|
||||||
*/
|
*/
|
||||||
@@ -215,7 +253,7 @@ export class LibraryStore extends EventTarget {
|
|||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
files: results,
|
files: results,
|
||||||
searchQuery: query,
|
searchQuery: query,
|
||||||
selectedFileId: results.at(0)?.id ?? null,
|
selectedFileId: results[0]?.id ?? null,
|
||||||
focusedFile: this.snapshot.focusedFile
|
focusedFile: this.snapshot.focusedFile
|
||||||
};
|
};
|
||||||
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
|
||||||
@@ -232,7 +270,10 @@ export class LibraryStore extends EventTarget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let visible: AudioFileSummary[];
|
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);
|
visible = files.filter((file) => file.categories.length === 0);
|
||||||
} else if (filter) {
|
} else if (filter) {
|
||||||
visible = files.filter((file) => file.categories.includes(filter));
|
visible = files.filter((file) => file.categories.includes(filter));
|
||||||
@@ -244,7 +285,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
visibleFiles: visible,
|
visibleFiles: visible,
|
||||||
selectedFileId,
|
selectedFileId,
|
||||||
focusedFile
|
focusedFile,
|
||||||
|
justSplitFileIds: this.snapshot.justSplitFileIds
|
||||||
};
|
};
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
}
|
}
|
||||||
@@ -258,7 +300,7 @@ export class LibraryStore extends EventTarget {
|
|||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
files,
|
files,
|
||||||
selectedFileId: files.at(0)?.id ?? null,
|
selectedFileId: files[0]?.id ?? null,
|
||||||
lastScan: summary,
|
lastScan: summary,
|
||||||
focusedFile: this.snapshot.focusedFile,
|
focusedFile: this.snapshot.focusedFile,
|
||||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||||
@@ -267,6 +309,44 @@ export class LibraryStore extends EventTarget {
|
|||||||
return summary;
|
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.
|
* Applies tag mutations to both the backend and the cached snapshot.
|
||||||
*/
|
*/
|
||||||
@@ -361,6 +441,40 @@ export class LibraryStore extends EventTarget {
|
|||||||
this.emitChange();
|
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.
|
* Assigns a new library path and refreshes the cached settings + file list.
|
||||||
*/
|
*/
|
||||||
@@ -371,7 +485,7 @@ export class LibraryStore extends EventTarget {
|
|||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
settings,
|
settings,
|
||||||
files,
|
files,
|
||||||
selectedFileId: files.at(0)?.id ?? null,
|
selectedFileId: files[0]?.id ?? null,
|
||||||
categoryFilter: null,
|
categoryFilter: null,
|
||||||
focusedFile: this.snapshot.focusedFile,
|
focusedFile: this.snapshot.focusedFile,
|
||||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||||
@@ -405,8 +519,13 @@ export class LibraryStore extends EventTarget {
|
|||||||
|
|
||||||
private refreshVisibleFiles(preferredId?: number | null, emit = true): void {
|
private refreshVisibleFiles(preferredId?: number | null, emit = true): void {
|
||||||
const { files, categoryFilter } = this.snapshot;
|
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[];
|
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);
|
visible = files.filter((file) => file.categories.length === 0);
|
||||||
} else if (categoryFilter) {
|
} else if (categoryFilter) {
|
||||||
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
||||||
@@ -443,7 +562,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
visibleFiles: visible,
|
visibleFiles: visible,
|
||||||
selectedFileId: nextSelected,
|
selectedFileId: nextSelected,
|
||||||
selectedFileIds: nextSelectionSet,
|
selectedFileIds: nextSelectionSet,
|
||||||
focusedFile: nextFocused
|
focusedFile: nextFocused,
|
||||||
|
justSplitFileIds: justSplitIds
|
||||||
};
|
};
|
||||||
|
|
||||||
if (emit) {
|
if (emit) {
|
||||||
@@ -461,7 +581,7 @@ export class LibraryStore extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (enforceVisible) {
|
if (enforceVisible) {
|
||||||
return visible.at(0)?.id ?? null;
|
return visible[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
return desiredId;
|
return desiredId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,6 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #0c0f13;
|
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;
|
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 {
|
.status-banner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.25rem 1rem;
|
padding: 0.25rem 1rem;
|
||||||
@@ -318,6 +397,7 @@ select,
|
|||||||
|
|
||||||
.file-list-search .search-input {
|
.file-list-search .search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-list-items {
|
.file-list-items {
|
||||||
@@ -429,6 +509,55 @@ select,
|
|||||||
overflow: hidden;
|
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 {
|
.file-detail {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -482,6 +611,23 @@ select,
|
|||||||
opacity: 0.7;
|
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 {
|
.file-meta-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -745,11 +891,11 @@ select,
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: none;
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0;
|
||||||
background: rgba(12, 16, 22, 0.65);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-category-chip-list {
|
.selected-category-chip-list {
|
||||||
@@ -768,21 +914,20 @@ select,
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
border-radius: 999px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--category-color-base, rgba(39, 110, 241, 0.5));
|
border: 1px solid var(--category-color-base, rgba(39, 110, 241, 0.5));
|
||||||
background: var(--category-color-soft, rgba(39, 110, 241, 0.2));
|
background: var(--category-color-soft, rgba(39, 110, 241, 0.2));
|
||||||
color: var(--category-color-text, inherit);
|
color: var(--category-color-text, inherit);
|
||||||
padding: 0.35rem 0.8rem;
|
padding: 0.35rem 0.5rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, transform 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-category-chip:hover {
|
.selected-category-chip:hover {
|
||||||
background: var(--category-color-strong, rgba(39, 110, 241, 0.35));
|
background: var(--category-color-strong, rgba(39, 110, 241, 0.35));
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-category-chip-label {
|
.selected-category-chip-label {
|
||||||
@@ -1564,3 +1709,342 @@ select,
|
|||||||
gap: 1rem;
|
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
0
src/renderer/src/types/assets.d.ts
vendored
Normal file
@@ -3,8 +3,11 @@ export const IPC_CHANNELS = {
|
|||||||
settingsGet: 'settings:get',
|
settingsGet: 'settings:get',
|
||||||
settingsSetLibrary: 'settings:set-library',
|
settingsSetLibrary: 'settings:set-library',
|
||||||
dialogSelectLibrary: 'dialog:select-library',
|
dialogSelectLibrary: 'dialog:select-library',
|
||||||
|
dialogSelectImportFolder: 'dialog:select-import-folder',
|
||||||
|
systemListDrives: 'system:list-drives',
|
||||||
libraryScan: 'library:scan',
|
libraryScan: 'library:scan',
|
||||||
libraryList: 'library:list',
|
libraryList: 'library:list',
|
||||||
|
libraryGetById: 'library:get-by-id',
|
||||||
libraryDuplicates: 'library:duplicates',
|
libraryDuplicates: 'library:duplicates',
|
||||||
libraryRename: 'library:rename',
|
libraryRename: 'library:rename',
|
||||||
libraryMove: 'library:move',
|
libraryMove: 'library:move',
|
||||||
@@ -12,11 +15,13 @@ export const IPC_CHANNELS = {
|
|||||||
libraryCustomName: 'library:custom-name',
|
libraryCustomName: 'library:custom-name',
|
||||||
libraryOpenFolder: 'library:open-folder',
|
libraryOpenFolder: 'library:open-folder',
|
||||||
libraryDelete: 'library:delete',
|
libraryDelete: 'library:delete',
|
||||||
|
librarySplit: 'library:split',
|
||||||
libraryBuffer: 'library:buffer',
|
libraryBuffer: 'library:buffer',
|
||||||
libraryMetadata: 'library:metadata',
|
libraryMetadata: 'library:metadata',
|
||||||
libraryMetadataSuggestions: 'library:metadata-suggestions',
|
libraryMetadataSuggestions: 'library:metadata-suggestions',
|
||||||
libraryUpdateMetadata: 'library:update-metadata',
|
libraryUpdateMetadata: 'library:update-metadata',
|
||||||
libraryWaveformPreview: 'library:waveform-preview',
|
libraryWaveformPreview: 'library:waveform-preview',
|
||||||
|
libraryImport: 'library:import',
|
||||||
tagsUpdate: 'tags:update',
|
tagsUpdate: 'tags:update',
|
||||||
categoriesList: 'categories:list',
|
categoriesList: 'categories:list',
|
||||||
searchQuery: 'search:query'
|
searchQuery: 'search:query'
|
||||||
@@ -36,8 +41,16 @@ export interface RendererApi {
|
|||||||
setLibraryPath(path: string): Promise<import('./models').AppSettings>;
|
setLibraryPath(path: string): Promise<import('./models').AppSettings>;
|
||||||
/** Triggers a manual rescan of the library. */
|
/** Triggers a manual rescan of the library. */
|
||||||
rescanLibrary(): Promise<import('./models').LibraryScanSummary>;
|
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. */
|
/** Fetches the current list of audio files. */
|
||||||
listAudioFiles(): Promise<import('./models').AudioFileSummary[]>;
|
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. */
|
/** Fetches groups of duplicate files based on checksum. */
|
||||||
listDuplicates(): Promise<{ checksum: string; files: import('./models').AudioFileSummary[] }[]>;
|
listDuplicates(): Promise<{ checksum: string; files: import('./models').AudioFileSummary[] }[]>;
|
||||||
/** Requests a rename for the target file. */
|
/** Requests a rename for the target file. */
|
||||||
@@ -45,31 +58,39 @@ export interface RendererApi {
|
|||||||
/** Moves a file to another subdirectory under the library root. */
|
/** Moves a file to another subdirectory under the library root. */
|
||||||
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
|
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Automatically organizes a file based on its categories. */
|
/** 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. */
|
/** Updates the custom name for a file. */
|
||||||
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
|
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Opens the file's containing folder in the system file explorer. */
|
/** Opens the file's containing folder in the system file explorer. */
|
||||||
openFileFolder(fileId: number): Promise<void>;
|
openFileFolder(fileId: number): Promise<void>;
|
||||||
/** Deletes files from disk and database. */
|
/** Deletes files from disk and database. */
|
||||||
deleteFiles(fileIds: number[]): Promise<void>;
|
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. */
|
/** Fetches the binary data required for playback. */
|
||||||
getAudioBuffer(fileId: number): Promise<import('./models').AudioBufferPayload>;
|
getAudioBuffer(fileId: number): Promise<import('./models').AudioBufferPayload>;
|
||||||
/** Returns a lightweight waveform preview for rendering list backgrounds. */
|
/** Returns a lightweight waveform preview for rendering list backgrounds. */
|
||||||
getWaveformPreview(fileId: number, pointCount?: number): Promise<{ samples: number[]; rms: number }>;
|
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>;
|
updateTagging(payload: import('./models').TagUpdatePayload): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Returns the catalog of UCS categories. */
|
/** Returns the catalog of UCS categories. */
|
||||||
listCategories(): Promise<import('./models').CategoryRecord[]>;
|
listCategories(): Promise<import('./models').CategoryRecord[]>;
|
||||||
/** Runs fuzzy search returning ranked results. */
|
/** Runs fuzzy search returning ranked results. */
|
||||||
search(query: string): Promise<import('./models').AudioFileSummary[]>;
|
search(query: string): Promise<import('./models').AudioFileSummary[]>;
|
||||||
/** Reads embedded metadata from a WAV file. */
|
/** 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. */
|
/** Lists distinct metadata values gathered from the library for quick suggestions. */
|
||||||
listMetadataSuggestions(): Promise<{ authors: string[] }>;
|
listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }>;
|
||||||
/** Updates metadata (author, rating) without organizing the file. */
|
/** Updates metadata (author, copyright, rating) without organizing the file. */
|
||||||
updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void>;
|
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. */
|
/** 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 {
|
declare global {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface AudioFileSummary {
|
|||||||
bitDepth: number | null;
|
bitDepth: number | null;
|
||||||
/** Content checksum used for duplicate detection. */
|
/** Content checksum used for duplicate detection. */
|
||||||
checksum: string | null;
|
checksum: string | null;
|
||||||
|
/** Identifier of the source file this entry originated from, if any. */
|
||||||
|
parentFileId: number | null;
|
||||||
/** Applied descriptive tags. */
|
/** Applied descriptive tags. */
|
||||||
tags: string[];
|
tags: string[];
|
||||||
/** Associated UCS categories. */
|
/** Associated UCS categories. */
|
||||||
@@ -76,6 +78,35 @@ export interface LibraryScanSummary {
|
|||||||
total: number;
|
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.
|
* Shape of persisted application settings.
|
||||||
*/
|
*/
|
||||||
@@ -90,8 +121,8 @@ export interface AppSettings {
|
|||||||
export interface TagUpdatePayload {
|
export interface TagUpdatePayload {
|
||||||
/** Target audio file id. */
|
/** Target audio file id. */
|
||||||
fileId: number;
|
fileId: number;
|
||||||
/** Free-form tag collection. */
|
/** Optional free-form tag collection. When omitted, existing tags are preserved. */
|
||||||
tags: string[];
|
tags?: string[];
|
||||||
/** UCS category identifiers to attach. */
|
/** UCS category identifiers to attach. */
|
||||||
categories: string[];
|
categories: string[];
|
||||||
}
|
}
|
||||||
@@ -107,3 +138,37 @@ export interface AudioBufferPayload {
|
|||||||
/** MIME type for the audio data. */
|
/** MIME type for the audio data. */
|
||||||
mimeType: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
*/
|
|
||||||
@@ -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']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user