mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-25 19:26:02 +10:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d3d616574 | |||
| 93d0da3496 | |||
| 761b1574e7 | |||
| 953c22bec3 | |||
| acf4b8d8b7 | |||
| fe38e7e8f2 | |||
| e16abd7370 | |||
| 9ade1cc7dd | |||
| 7ecb65153c | |||
| 65dadc9e3f | |||
| 9e487fed7b | |||
| e16b069ddd | |||
| 947a510646 | |||
| 343efc18ed | |||
| c2df7198bd | |||
| 69d98c1f1a | |||
| ed3d9b60df | |||
| 18a2d3ee26 | |||
| cf6c14c161 | |||
| 524bbdce8a | |||
| 3569a46ebb | |||
| b4bfbd3ef6 | |||
| c33371b206 |
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
|
||||||
|
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
|
||||||
|
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
|
|
||||||
14
README.md
14
README.md
@@ -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
|
||||||
|
|
||||||
@@ -45,19 +46,6 @@ A desktop application for organizing, tagging, and managing WAV audio files with
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
|
|||||||
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 |
68
package.json
68
package.json
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-sort",
|
"name": "audio-sort",
|
||||||
"version": "0.3.0",
|
"version": "0.5.2",
|
||||||
"description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
|
"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": {
|
||||||
@@ -16,33 +21,36 @@
|
|||||||
"start": "cross-env NODE_ENV=production electron ./dist/main/main/index.js",
|
"start": "cross-env NODE_ENV=production electron ./dist/main/main/index.js",
|
||||||
"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",
|
||||||
|
"dist:linux": "npm run build && electron-builder --linux",
|
||||||
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.0.0",
|
"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",
|
||||||
"standardized-audio-context": "^25.3.12",
|
"react": "^19.2.0",
|
||||||
"react": "^18.3.1",
|
"react-dom": "^19.2.0",
|
||||||
"react-dom": "^18.3.1",
|
"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": "^32.0.0",
|
"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",
|
||||||
@@ -55,13 +63,29 @@
|
|||||||
"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": "data/UCS.csv",
|
"from": "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();
|
||||||
|
|
||||||
@@ -47,16 +47,22 @@ export class MainApp {
|
|||||||
|
|
||||||
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,6 +148,8 @@ 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);
|
||||||
@@ -143,6 +161,7 @@ export class MainApp {
|
|||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
|
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);
|
||||||
@@ -215,7 +234,7 @@ export class MainApp {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => {
|
ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => {
|
||||||
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
||||||
const targetWindow = this.mainWindow;
|
const targetWindow = this.mainWindow;
|
||||||
const result = targetWindow
|
const result = targetWindow
|
||||||
? await dialog.showOpenDialog(targetWindow, options)
|
? await dialog.showOpenDialog(targetWindow, options)
|
||||||
@@ -223,6 +242,17 @@ 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) =>
|
ipcMain.handle(IPC_CHANNELS.libraryGetById, async (_event: IpcMainInvokeEvent, fileId: number) =>
|
||||||
@@ -306,6 +336,19 @@ export class MainApp {
|
|||||||
) =>
|
) =>
|
||||||
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -360,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();
|
||||||
|
|||||||
@@ -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, SplitSegmentRequest, 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;
|
||||||
@@ -32,6 +52,7 @@ export class LibraryService {
|
|||||||
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 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,
|
||||||
@@ -282,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.
|
||||||
*/
|
*/
|
||||||
@@ -1342,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.
|
||||||
*/
|
*/
|
||||||
@@ -1353,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ export class TagService {
|
|||||||
.filter((entry) => entry.length > 0);
|
.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 trimmedTitle = metadata.title?.toString().trim();
|
||||||
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
|
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
|
||||||
const trimmedAuthor = metadata.author?.toString().trim();
|
const trimmedAuthor = metadata.author?.toString().trim();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
AudioBufferPayload,
|
AudioBufferPayload,
|
||||||
AudioFileSummary,
|
AudioFileSummary,
|
||||||
CategoryRecord,
|
CategoryRecord,
|
||||||
|
LibraryImportResult,
|
||||||
LibraryScanSummary,
|
LibraryScanSummary,
|
||||||
SplitSegmentRequest,
|
SplitSegmentRequest,
|
||||||
TagUpdatePayload
|
TagUpdatePayload
|
||||||
@@ -26,9 +27,18 @@ 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. */
|
/** Retrieves a single audio file summary by id, returning null when the record no longer exists. */
|
||||||
async getAudioFileById(fileId: number): Promise<AudioFileSummary | null> {
|
async getAudioFileById(fileId: number): Promise<AudioFileSummary | null> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryGetById, fileId);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryGetById, fileId);
|
||||||
@@ -87,8 +97,8 @@ const api: RendererApi = {
|
|||||||
): Promise<void> {
|
): 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';
|
||||||
@@ -10,7 +10,7 @@ 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';
|
type RightPanelTab = 'listen' | 'edit';
|
||||||
|
|
||||||
@@ -25,14 +25,18 @@ function App(): JSX.Element {
|
|||||||
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 [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) {
|
||||||
@@ -44,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);
|
||||||
@@ -52,18 +57,6 @@ 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]
|
||||||
@@ -155,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);
|
||||||
@@ -211,7 +272,7 @@ function App(): JSX.Element {
|
|||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCustomName = async (customName: string | null) => {
|
const handleUpdateCustomName = async (customName: string | null) => {
|
||||||
@@ -274,8 +335,7 @@ function App(): JSX.Element {
|
|||||||
setActiveTab('listen');
|
setActiveTab('listen');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditModeSplitComplete = async (created: AudioFileSummary[]) => {
|
const handleEditModeSplitComplete = (created: AudioFileSummary[]) => {
|
||||||
await libraryStore.rescan();
|
|
||||||
setActiveTab('listen');
|
setActiveTab('listen');
|
||||||
if (created.length > 0) {
|
if (created.length > 0) {
|
||||||
libraryStore.selectFile(created[0].id);
|
libraryStore.selectFile(created[0].id);
|
||||||
@@ -294,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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 |
@@ -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,6 +2,7 @@ import type {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
AudioFileSummary,
|
AudioFileSummary,
|
||||||
CategoryRecord,
|
CategoryRecord,
|
||||||
|
LibraryImportResult,
|
||||||
LibraryScanSummary,
|
LibraryScanSummary,
|
||||||
SplitSegmentRequest,
|
SplitSegmentRequest,
|
||||||
TagUpdatePayload
|
TagUpdatePayload
|
||||||
@@ -10,7 +11,9 @@ import type {
|
|||||||
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;
|
||||||
@@ -25,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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +48,8 @@ export class LibraryStore extends EventTarget {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
categoryFilter: null,
|
categoryFilter: null,
|
||||||
lastScan: null,
|
lastScan: null,
|
||||||
metadataSuggestionsVersion: 0
|
metadataSuggestionsVersion: 0,
|
||||||
|
justSplitFileIds: []
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,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,
|
||||||
@@ -70,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);
|
||||||
}
|
}
|
||||||
@@ -246,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);
|
||||||
@@ -263,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));
|
||||||
@@ -275,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();
|
||||||
}
|
}
|
||||||
@@ -289,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
|
||||||
@@ -298,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.
|
||||||
*/
|
*/
|
||||||
@@ -398,21 +447,29 @@ export class LibraryStore extends EventTarget {
|
|||||||
public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
|
public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
|
||||||
const created = await window.api.splitFile(fileId, segments);
|
const created = await window.api.splitFile(fileId, segments);
|
||||||
const files = await window.api.listAudioFiles();
|
const files = await window.api.listAudioFiles();
|
||||||
const { categoryFilter } = this.snapshot;
|
const createdIds = created.map((record) => record.id);
|
||||||
let visible: AudioFileSummary[];
|
const fileLookup = new Map(files.map((record) => [record.id, record] as const));
|
||||||
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
|
// Prefer fresh copies from the library list so downstream consumers see current metadata.
|
||||||
visible = files.filter((file) => file.categories.length === 0);
|
const justSplitRecords: AudioFileSummary[] = createdIds
|
||||||
} else if (categoryFilter) {
|
.map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null)
|
||||||
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
.filter((record): record is AudioFileSummary => record !== null);
|
||||||
} else {
|
|
||||||
visible = files.slice();
|
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 = {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
files,
|
files,
|
||||||
visibleFiles: visible,
|
visibleFiles: justSplitRecords,
|
||||||
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
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();
|
this.emitChange();
|
||||||
return created;
|
return created;
|
||||||
@@ -428,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
|
||||||
@@ -462,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));
|
||||||
@@ -500,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) {
|
||||||
@@ -518,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,95 @@ body {
|
|||||||
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;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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',
|
libraryGetById: 'library:get-by-id',
|
||||||
@@ -19,6 +21,7 @@ export const IPC_CHANNELS = {
|
|||||||
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'
|
||||||
@@ -38,8 +41,14 @@ 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. */
|
/** Retrieves a single audio file summary by id, or null if it cannot be resolved. */
|
||||||
getAudioFileById(fileId: number): Promise<import('./models').AudioFileSummary | null>;
|
getAudioFileById(fileId: number): Promise<import('./models').AudioFileSummary | null>;
|
||||||
/** Fetches groups of duplicate files based on checksum. */
|
/** Fetches groups of duplicate files based on checksum. */
|
||||||
@@ -81,7 +90,7 @@ export interface RendererApi {
|
|||||||
metadata: { author?: string | null; copyright?: string | null; rating?: number }
|
metadata: { author?: string | null; copyright?: string | null; rating?: number }
|
||||||
): Promise<void>;
|
): 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 {
|
||||||
|
|||||||
@@ -78,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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user