feat: implement drive import functionality

This commit is contained in:
2025-11-12 06:18:06 +11:00
parent 69d98c1f1a
commit c2df7198bd
9 changed files with 752 additions and 35 deletions

View File

@@ -47,15 +47,21 @@ export class MainApp {
this.searchService.rebuildIndex();
this.registerIpcHandlers();
this.createMenu();
await this.createMenu();
this.createWindow();
}
/**
* Creates the application menu.
*/
private createMenu(): void {
private async createMenu(): Promise<void> {
const isMac = process.platform === 'darwin';
const drives = await this.listAvailableDrives();
const driveSubmenu: Electron.MenuItemConstructorOptions[] = drives.map((drive) => ({
label: drive.label,
click: () => this.mainWindow?.webContents.send('import-from-drive', drive.path)
}));
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac ? [{
@@ -79,6 +85,16 @@ export class MainApp {
{
label: 'File',
submenu: [
{
label: 'Import From Folder...',
accelerator: isMac ? 'Cmd+Shift+I' : 'Ctrl+Shift+I',
click: () => this.mainWindow?.webContents.send('import-from-folder')
},
{
label: 'Import From Drive',
submenu: driveSubmenu.length > 0 ? driveSubmenu : [{ label: 'No drives available', enabled: false }]
},
{ type: 'separator' as const },
{
label: 'Rescan Library',
accelerator: isMac ? 'Cmd+R' : 'Ctrl+R',
@@ -132,6 +148,8 @@ export class MainApp {
ipcMain.removeHandler(IPC_CHANNELS.settingsGet);
ipcMain.removeHandler(IPC_CHANNELS.settingsSetLibrary);
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectLibrary);
ipcMain.removeHandler(IPC_CHANNELS.dialogSelectImportFolder);
ipcMain.removeHandler(IPC_CHANNELS.systemListDrives);
ipcMain.removeHandler(IPC_CHANNELS.libraryScan);
ipcMain.removeHandler(IPC_CHANNELS.libraryList);
ipcMain.removeHandler(IPC_CHANNELS.libraryRename);
@@ -143,6 +161,7 @@ export class MainApp {
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
ipcMain.removeHandler(IPC_CHANNELS.libraryImport);
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
ipcMain.removeHandler(IPC_CHANNELS.searchQuery);
@@ -223,6 +242,17 @@ export class MainApp {
return result.canceled ? null : result.filePaths[0] ?? null;
});
ipcMain.handle(IPC_CHANNELS.dialogSelectImportFolder, async () => {
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
const targetWindow = this.mainWindow;
const result = targetWindow
? await dialog.showOpenDialog(targetWindow, options)
: await dialog.showOpenDialog(options);
return result.canceled ? null : result.filePaths[0] ?? null;
});
ipcMain.handle(IPC_CHANNELS.systemListDrives, async () => this.listAvailableDrives());
ipcMain.handle(IPC_CHANNELS.libraryScan, async () => this.requireLibrary().scanLibrary());
ipcMain.handle(IPC_CHANNELS.libraryList, async () => this.requireLibrary().listFiles());
ipcMain.handle(IPC_CHANNELS.libraryGetById, async (_event: IpcMainInvokeEvent, fileId: number) =>
@@ -306,6 +336,19 @@ export class MainApp {
) =>
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();
}
private async listAvailableDrives(): Promise<Array<{ path: string; label: string }>> {
if (process.platform === 'win32') {
try {
const { exec } = await import('node:child_process');
const { promisify } = await import('node:util');
const execAsync = promisify(exec);
// Query Windows Management Instrumentation for drive info
const { stdout } = await execAsync('wmic logicaldisk get deviceid,drivetype', {
timeout: 5000,
windowsHide: true
});
const lines = stdout.trim().split('\n').slice(1); // Skip header
const drives: Array<{ path: string; type: string }> = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Parse "C: 3" format (DeviceID and DriveType)
const match = trimmed.match(/^([A-Z]:)\s+(\d+)$/);
if (!match) continue;
const [, deviceId, driveType] = match;
const drivePath = `${deviceId}\\`;
// Verify drive is actually accessible before including it
try {
await fs.promises.access(drivePath);
} catch {
continue; // Skip if not accessible (unplugged/ejected)
}
// DriveType: 2=Removable, 3=Local Fixed, 4=Network, 5=CD-ROM, 6=RAM Disk
let label = drivePath;
if (driveType === '2') {
label = `${drivePath} (Removable)`;
}
drives.push({ path: drivePath, type: label });
}
return drives.sort((a, b) => a.path.localeCompare(b.path)).map((d) => ({ path: d.path, label: d.type }));
} catch (error) {
console.warn('Failed to query drive types via wmic, falling back to simple enumeration', error);
// Fallback to basic enumeration
const drives: { path: string; label: string }[] = [];
const probes: Promise<void>[] = [];
for (let code = 65; code <= 90; code += 1) {
const letter = String.fromCharCode(code);
const drivePath = `${letter}:\\`;
const probe = fs.promises
.access(drivePath)
.then(() => {
drives.push({ path: drivePath, label: drivePath });
})
.catch(() => undefined);
probes.push(probe);
}
await Promise.all(probes);
return drives.sort((a, b) => a.path.localeCompare(b.path));
}
}
if (process.platform === 'darwin') {
try {
const entries = await fs.promises.readdir('/Volumes', { withFileTypes: true });
const volumes = entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({ path: path.join('/Volumes', entry.name), label: path.join('/Volumes', entry.name) }));
return volumes.length > 0 ? volumes : [{ path: '/', label: '/' }];
} catch {
return [{ path: '/', label: '/' }];
}
}
return [{ path: '/', label: '/' }];
}
private buildSearchBases(): string[] {
const bases = new Set<string>();
const appPath = app.getAppPath();

View File

@@ -4,7 +4,18 @@ import { createHash } from 'node:crypto';
import path from 'node:path';
import fg from 'fast-glob';
import { parse } from 'csv-parse/sync';
import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, 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 { SettingsService } from './SettingsService';
import { TagService } from './TagService';
@@ -292,6 +303,138 @@ export class LibraryService {
return cleanedCount;
}
/**
* Imports external WAV files from the provided sources, copying them into the library while
* skipping duplicates based on the audio checksum. Imported files land under `_Imports/<date>`.
*/
public async importExternalSources(sourcePaths: string[]): Promise<LibraryImportResult> {
// eslint-disable-next-line no-console -- Log import start for debugging.
console.log('Starting import from:', sourcePaths);
if (!Array.isArray(sourcePaths) || sourcePaths.length === 0) {
return { imported: [], skipped: [], failed: [] };
}
const libraryRoot = this.settings.ensureLibraryPath();
const normalisedLibraryRoot = this.normalizeAbsolutePath(libraryRoot);
const libraryRootWithSlash = normalisedLibraryRoot.endsWith('/')
? normalisedLibraryRoot
: `${normalisedLibraryRoot}/`;
const existingFiles = this.database.listFiles();
const knownChecksums = new Set<string>();
const knownPaths = new Set<string>();
for (const file of existingFiles) {
if (typeof file.checksum === 'string' && file.checksum.length > 0) {
knownChecksums.add(file.checksum);
}
knownPaths.add(this.normalizeAbsolutePath(file.absolutePath));
}
const importFolderRelativeBase = path.join('_Imports', new Date().toISOString().slice(0, 10));
const importFolderAbsolute = path.join(libraryRoot, importFolderRelativeBase);
await fs.mkdir(importFolderAbsolute, { recursive: true });
const { files: candidateFiles, failures } = await this.collectImportCandidates(sourcePaths);
// eslint-disable-next-line no-console -- Log discovered files for debugging.
console.log(`Found ${candidateFiles.length} candidate files, ${failures.length} collection failures`);
if (failures.length > 0) {
// eslint-disable-next-line no-console -- Log collection failures for debugging.
console.log('Collection failures:', failures);
}
const imported: AudioFileSummary[] = [];
const skipped: ImportSkipEntry[] = [];
const failed: ImportFailureEntry[] = [...failures];
const usedNames = new Set<string>();
const allowedExtensions = new Set(['.wav', '.wave']);
const folderRelativeNormalised = this.normalizeRelativePath(
path.relative(libraryRoot, importFolderAbsolute)
);
const folderForJoin =
folderRelativeNormalised === '.' || folderRelativeNormalised.length === 0
? ''
: folderRelativeNormalised;
for (const candidate of candidateFiles) {
const extension = path.extname(candidate).toLowerCase();
if (!allowedExtensions.has(extension)) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: unsupported extension ${extension}`);
skipped.push({ path: candidate, reason: 'unsupported' });
continue;
}
const normalisedCandidate = this.normalizeAbsolutePath(candidate);
if (
normalisedCandidate === normalisedLibraryRoot ||
normalisedCandidate.startsWith(libraryRootWithSlash)
) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: already inside library`);
skipped.push({ path: candidate, reason: 'inside-library' });
continue;
}
if (knownPaths.has(normalisedCandidate)) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: duplicate path`);
skipped.push({ path: candidate, reason: 'duplicate' });
continue;
}
// eslint-disable-next-line no-console -- Log checksum computation for debugging.
console.log(`Computing checksum for ${candidate}...`);
const checksum = await this.computeFileChecksum(candidate);
if (!checksum) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: checksum computation failed`);
skipped.push({ path: candidate, reason: 'checksum' });
continue;
}
if (knownChecksums.has(checksum)) {
// eslint-disable-next-line no-console -- Log skip reason for debugging.
console.log(`Skipping ${candidate}: duplicate checksum ${checksum}`);
skipped.push({ path: candidate, reason: 'duplicate' });
continue;
}
// eslint-disable-next-line no-console -- Log import attempt for debugging.
console.log(`Attempting to import ${candidate} with checksum ${checksum}...`);
try {
const record = await this.copyAndRegisterImportedFile({
sourcePath: candidate,
checksum,
importFolderAbsolute,
folderForJoin,
libraryRoot,
usedNames
});
imported.push(record);
knownChecksums.add(checksum);
knownPaths.add(this.normalizeAbsolutePath(record.absolutePath));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console -- Log import failures for debugging.
console.error(`Failed to import ${candidate}:`, errorMessage);
failed.push({
path: candidate,
message: errorMessage
});
}
}
if (imported.length > 0) {
this.resetMetadataSuggestionsCache();
this.search.rebuildIndex();
}
return { imported, skipped, failed };
}
/**
* Renames a file while keeping it in the current directory.
*/
@@ -1352,6 +1495,142 @@ export class LibraryService {
return value.replace(/\\/g, '/');
}
/**
* Gathers candidate audio files from the provided sources, handling both folders and individual files.
*/
private async collectImportCandidates(sourcePaths: string[]): Promise<{
files: string[];
failures: ImportFailureEntry[];
}> {
const discovered = new Set<string>();
const failures: ImportFailureEntry[] = [];
const uniqueSources = Array.from(
new Set((sourcePaths ?? []).map((entry) => entry?.trim()).filter((entry): entry is string => Boolean(entry)))
);
for (const rawSource of uniqueSources) {
const absoluteSource = path.resolve(rawSource);
try {
const stats = await fs.stat(absoluteSource);
if (stats.isDirectory()) {
try {
const matches = await fg(['**/*.wav', '**/*.wave'], {
cwd: absoluteSource,
absolute: true,
onlyFiles: true,
suppressErrors: true,
caseSensitiveMatch: false
});
for (const match of matches) {
discovered.add(path.resolve(match));
}
} catch (error) {
failures.push({
path: absoluteSource,
message: error instanceof Error ? error.message : String(error)
});
}
} else if (stats.isFile()) {
discovered.add(absoluteSource);
} else {
failures.push({ path: absoluteSource, message: 'Unsupported file system entry.' });
}
} catch (error) {
failures.push({
path: absoluteSource,
message: error instanceof Error ? error.message : String(error)
});
}
}
const files = Array.from(discovered).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
return { files, failures };
}
/**
* Copies a single source file into the library and registers it in the database, returning the stored record.
*/
private async copyAndRegisterImportedFile(options: {
sourcePath: string;
checksum: string;
importFolderAbsolute: string;
folderForJoin: string;
libraryRoot: string;
usedNames: Set<string>;
}): Promise<AudioFileSummary> {
const { sourcePath, checksum, importFolderAbsolute, folderForJoin, libraryRoot, usedNames } = options;
const extension = path.extname(sourcePath);
const baseName = path.basename(sourcePath, extension);
let sanitizedBase = this.organization.sanitizeCustomName(baseName);
if (!sanitizedBase) {
sanitizedBase = 'Imported';
}
let attempt = 0;
let candidateName: string = '';
let candidateAbsolutePath: string = '';
while (true) {
if (attempt === 0) {
candidateName = this.normaliseFileName(`${sanitizedBase}.wav`);
} else {
const suffix = this.organization.formatSequenceNumber(attempt);
candidateName = this.normaliseFileName(`${sanitizedBase}_${suffix}.wav`);
}
if (!usedNames.has(candidateName)) {
candidateAbsolutePath = path.join(importFolderAbsolute, candidateName);
const exists = await this.pathExists(candidateAbsolutePath);
if (!exists) {
break;
}
}
attempt += 1;
if (attempt > 9999) {
throw new Error('Unable to allocate a unique filename for the imported file.');
}
}
usedNames.add(candidateName);
this.assertWithinLibrary(libraryRoot, candidateAbsolutePath);
await fs.copyFile(sourcePath, candidateAbsolutePath);
try {
const stats = await fs.stat(candidateAbsolutePath);
const metadata = await this.extractAudioMetadata(candidateAbsolutePath);
const relativePath = this.toLibraryRelativePath(folderForJoin, candidateName);
const record = this.database.upsertFile({
absolutePath: candidateAbsolutePath,
relativePath,
fileName: candidateName,
displayName: path.basename(candidateName, path.extname(candidateName)),
modifiedAt: stats.mtimeMs,
createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
size: stats.size,
durationMs: metadata.durationMs,
sampleRate: metadata.sampleRate,
bitDepth: metadata.bitDepth,
checksum,
tags: metadata.tags,
categories: metadata.categories
});
try {
const embedded = this.tagService.readMetadata(candidateAbsolutePath);
this.updateMetadataSuggestionsCache(embedded.author ?? null);
} catch (metadataError) {
// eslint-disable-next-line no-console -- Import should continue even if metadata read fails.
console.warn('Failed to read metadata from imported file', metadataError);
}
return record;
} catch (error) {
await fs.rm(candidateAbsolutePath, { force: true }).catch(() => undefined);
throw error;
}
}
/**
* Resolves and caches the music-metadata parseFile implementation.
*/

View File

@@ -5,6 +5,7 @@ import type {
AudioBufferPayload,
AudioFileSummary,
CategoryRecord,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
@@ -26,9 +27,18 @@ const api: RendererApi = {
async rescanLibrary(): Promise<LibraryScanSummary> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryScan);
},
async selectImportFolder(): Promise<string | null> {
return ipcRenderer.invoke(IPC_CHANNELS.dialogSelectImportFolder);
},
async listSystemDrives(): Promise<string[]> {
return ipcRenderer.invoke(IPC_CHANNELS.systemListDrives);
},
async listAudioFiles(): Promise<AudioFileSummary[]> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryList);
},
async importExternalSources(paths: string[]): Promise<LibraryImportResult> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryImport, paths);
},
/** Retrieves a single audio file summary by id, returning null when the record no longer exists. */
async getAudioFileById(fileId: number): Promise<AudioFileSummary | null> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryGetById, fileId);
@@ -87,8 +97,8 @@ const api: RendererApi = {
): Promise<void> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata);
},
onMenuAction(channel: string, callback: () => void): () => void {
const listener = () => callback();
onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void {
const listener = (_event: Electron.IpcRendererEvent, payload?: unknown) => callback(payload);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type JSX } from 'react';
import { useCallback, useEffect, useMemo, useState, type JSX } from 'react';
import FileList from './components/FileList';
import FileDetailPanel from './components/FileDetailPanel';
import MultiFileEditor from './components/MultiFileEditor';
@@ -10,7 +10,7 @@ import EditModePanel from './components/edit/EditModePanel';
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore';
import type { AudioFileSummary } from '../../shared/models';
import type { AudioFileSummary, LibraryImportResult } from '../../shared/models';
type RightPanelTab = 'listen' | 'edit';
@@ -25,14 +25,18 @@ function App(): JSX.Element {
const [showStatusMessage, setShowStatusMessage] = useState(false);
const [statusFadingOut, setStatusFadingOut] = useState(false);
const [activeTab, setActiveTab] = useState<RightPanelTab>('listen');
const [importMessage, setImportMessage] = useState<string | null>(null);
const statusMessage = useMemo(() => {
if (importMessage) {
return importMessage;
}
if (!library.lastScan) {
return null;
}
const { added, updated, removed } = library.lastScan;
return `Scan complete: +${added} updated ${updated} removed ${removed}`;
}, [library.lastScan]);
}, [importMessage, library.lastScan]);
useEffect(() => {
if (statusMessage) {
@@ -44,6 +48,7 @@ function App(): JSX.Element {
const hideTimer = setTimeout(() => {
setShowStatusMessage(false);
setStatusFadingOut(false);
setImportMessage((current) => (current === statusMessage ? null : current));
}, 5000);
return () => {
clearTimeout(fadeTimer);
@@ -52,18 +57,6 @@ function App(): JSX.Element {
}
}, [statusMessage]);
useEffect(() => {
const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true));
const cleanup2 = window.api.onMenuAction('rescan-library', handleRescan);
const cleanup3 = window.api.onMenuAction('find-duplicates', handleFindDuplicates);
return () => {
cleanup1();
cleanup2();
cleanup3();
};
}, []);
const selectedFile = useMemo(
() => library.files.find((file) => file.id === library.selectedFileId) ?? null,
[library.files, library.selectedFileId]
@@ -155,14 +148,82 @@ function App(): JSX.Element {
void libraryStore.search(value);
};
const handleRescan = async () => {
const handleRescan = useCallback(async () => {
await libraryStore.rescan();
};
}, []);
const handleFindDuplicates = async () => {
const handleFindDuplicates = useCallback(async () => {
const duplicates = await window.api.listDuplicates();
setDuplicateGroups(duplicates);
}, []);
const summariseImportResult = useCallback((result: LibraryImportResult): string => {
const parts: string[] = [`${result.imported.length} added`];
if (result.skipped.length > 0) {
parts.push(`${result.skipped.length} skipped`);
}
if (result.failed.length > 0) {
parts.push(`${result.failed.length} failed`);
}
return `Import complete: ${parts.join(', ')}`;
}, []);
const notifyImportSuccess = useCallback((result: LibraryImportResult) => {
setImportMessage(summariseImportResult(result));
}, [summariseImportResult]);
const notifyImportFailure = useCallback(() => {
setImportMessage('Import failed. Check logs for details.');
}, []);
const handleImportFromFolder = useCallback(async () => {
try {
const result = await libraryStore.importFromFolder();
if (!result) {
return;
}
notifyImportSuccess(result);
} catch (error) {
console.error('Import failed', error);
notifyImportFailure();
}
}, [notifyImportFailure, notifyImportSuccess]);
const handleImportFromDrive = useCallback(async (drive: string) => {
try {
const result = await libraryStore.importFromDrive(drive);
notifyImportSuccess(result);
} catch (error) {
console.error('Import failed', error);
notifyImportFailure();
}
}, [notifyImportFailure, notifyImportSuccess]);
useEffect(() => {
const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true));
const cleanup2 = window.api.onMenuAction('rescan-library', () => {
void handleRescan();
});
const cleanup3 = window.api.onMenuAction('find-duplicates', () => {
void handleFindDuplicates();
});
const cleanup4 = window.api.onMenuAction('import-from-folder', () => {
void handleImportFromFolder();
});
const cleanup5 = window.api.onMenuAction('import-from-drive', (drive) => {
if (typeof drive === 'string') {
void handleImportFromDrive(drive);
}
});
return () => {
cleanup1();
cleanup2();
cleanup3();
cleanup4();
cleanup5();
};
}, [handleFindDuplicates, handleImportFromFolder, handleImportFromDrive, handleRescan]);
const handleKeepDuplicate = async (fileIdToKeep: number, fileIdsToDelete: number[]) => {
await window.api.deleteFiles(fileIdsToDelete);
@@ -211,7 +272,7 @@ function App(): JSX.Element {
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}, 5000);
};
const handleUpdateCustomName = async (customName: string | null) => {

View File

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

View File

@@ -2,6 +2,7 @@ import type {
AppSettings,
AudioFileSummary,
CategoryRecord,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
@@ -60,8 +61,8 @@ export class LibraryStore extends EventTarget {
window.api.listCategories(),
window.api.listAudioFiles()
]);
const firstFileId = files.at(0)?.id ?? null;
const firstFile = files.at(0) ?? null;
const firstFileId = files[0]?.id ?? null;
const firstFile = files[0] ?? null;
const nextVersion = this.snapshot.metadataSuggestionsVersion + 1;
this.snapshot = {
initialized: true,
@@ -252,7 +253,7 @@ export class LibraryStore extends EventTarget {
...this.snapshot,
files: results,
searchQuery: query,
selectedFileId: results.at(0)?.id ?? null,
selectedFileId: results[0]?.id ?? null,
focusedFile: this.snapshot.focusedFile
};
this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null);
@@ -299,7 +300,7 @@ export class LibraryStore extends EventTarget {
this.snapshot = {
...this.snapshot,
files,
selectedFileId: files.at(0)?.id ?? null,
selectedFileId: files[0]?.id ?? null,
lastScan: summary,
focusedFile: this.snapshot.focusedFile,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
@@ -308,6 +309,44 @@ export class LibraryStore extends EventTarget {
return summary;
}
/**
* Prompts the user to select a folder and imports audio files from it.
*/
public async importFromFolder(): Promise<LibraryImportResult | null> {
const folder = await window.api.selectImportFolder();
if (!folder) {
return null;
}
return this.executeImport([folder]);
}
/**
* Imports from a drive path, refreshing local caches when new files are added.
*/
public async importFromDrive(drivePath: string): Promise<LibraryImportResult> {
return this.executeImport([drivePath]);
}
private async executeImport(sources: string[]): Promise<LibraryImportResult> {
const trimmed = sources.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
if (trimmed.length === 0) {
return { imported: [], skipped: [], failed: [] };
}
const result = await window.api.importExternalSources(trimmed);
if (result.imported.length > 0) {
const files = await window.api.listAudioFiles();
const preferredId = result.imported[0]?.id ?? this.snapshot.selectedFileId ?? null;
this.snapshot = {
...this.snapshot,
files,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
};
this.refreshVisibleFiles(preferredId ?? undefined);
}
return result;
}
/**
* Applies tag mutations to both the backend and the cached snapshot.
*/
@@ -415,7 +454,7 @@ export class LibraryStore extends EventTarget {
.map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null)
.filter((record): record is AudioFileSummary => record !== null);
const primaryId = justSplitRecords.at(0)?.id ?? null;
const primaryId = justSplitRecords[0]?.id ?? null;
const nextSelection = new Set<number>(justSplitRecords.map((record) => record.id));
if (primaryId !== null) {
nextSelection.add(primaryId);
@@ -430,7 +469,7 @@ export class LibraryStore extends EventTarget {
justSplitFileIds: justSplitRecords.map((record) => record.id),
selectedFileId: primaryId,
selectedFileIds: nextSelection,
focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords.at(0) ?? null : null
focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords[0] ?? null : null
};
this.emitChange();
return created;
@@ -446,7 +485,7 @@ export class LibraryStore extends EventTarget {
...this.snapshot,
settings,
files,
selectedFileId: files.at(0)?.id ?? null,
selectedFileId: files[0]?.id ?? null,
categoryFilter: null,
focusedFile: this.snapshot.focusedFile,
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
@@ -542,7 +581,7 @@ export class LibraryStore extends EventTarget {
}
}
if (enforceVisible) {
return visible.at(0)?.id ?? null;
return visible[0]?.id ?? null;
}
return desiredId;
}

View File

@@ -174,6 +174,95 @@ body {
color: inherit;
}
.drive-picker {
width: min(28rem, 90vw);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.drive-picker__header h2 {
margin: 0 0 0.35rem 0;
font-size: 1.4rem;
}
.drive-picker__header p {
margin: 0;
opacity: 0.75;
font-size: 0.95rem;
}
.drive-picker__error {
padding: 0.75rem 1rem;
border-radius: 0.6rem;
background: rgba(220, 97, 97, 0.12);
border: 1px solid rgba(220, 97, 97, 0.35);
color: #f5d6d6;
font-size: 0.9rem;
}
.drive-picker__body {
min-height: 6rem;
}
.drive-picker__status {
margin: 0;
opacity: 0.75;
}
.drive-picker__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.drive-picker__drive {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3rem;
padding: 0.7rem 0.9rem;
border-radius: 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: inherit;
font: inherit;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.drive-picker__drive:hover:not(:disabled),
.drive-picker__drive:focus-visible {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.3);
}
.drive-picker__drive:disabled {
opacity: 0.6;
cursor: default;
}
.drive-picker__drive-label {
font-weight: 600;
font-size: 1rem;
}
.drive-picker__drive-hint {
font-size: 0.8rem;
opacity: 0.7;
}
.drive-picker__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.status-banner {
text-align: center;
padding: 0.25rem 1rem;

View File

@@ -3,6 +3,8 @@ export const IPC_CHANNELS = {
settingsGet: 'settings:get',
settingsSetLibrary: 'settings:set-library',
dialogSelectLibrary: 'dialog:select-library',
dialogSelectImportFolder: 'dialog:select-import-folder',
systemListDrives: 'system:list-drives',
libraryScan: 'library:scan',
libraryList: 'library:list',
libraryGetById: 'library:get-by-id',
@@ -19,6 +21,7 @@ export const IPC_CHANNELS = {
libraryMetadataSuggestions: 'library:metadata-suggestions',
libraryUpdateMetadata: 'library:update-metadata',
libraryWaveformPreview: 'library:waveform-preview',
libraryImport: 'library:import',
tagsUpdate: 'tags:update',
categoriesList: 'categories:list',
searchQuery: 'search:query'
@@ -38,8 +41,14 @@ export interface RendererApi {
setLibraryPath(path: string): Promise<import('./models').AppSettings>;
/** Triggers a manual rescan of the library. */
rescanLibrary(): Promise<import('./models').LibraryScanSummary>;
/** Opens a dialog to pick a folder to import audio from. */
selectImportFolder(): Promise<string | null>;
/** Lists available system drives for drive-level imports. */
listSystemDrives(): Promise<string[]>;
/** Fetches the current list of audio files. */
listAudioFiles(): Promise<import('./models').AudioFileSummary[]>;
/** Imports external audio files into the library. */
importExternalSources(paths: string[]): Promise<import('./models').LibraryImportResult>;
/** Retrieves a single audio file summary by id, or null if it cannot be resolved. */
getAudioFileById(fileId: number): Promise<import('./models').AudioFileSummary | null>;
/** Fetches groups of duplicate files based on checksum. */
@@ -81,7 +90,7 @@ export interface RendererApi {
metadata: { author?: string | null; copyright?: string | null; rating?: number }
): Promise<void>;
/** Listens for menu actions from the main process. Returns a cleanup function. */
onMenuAction(channel: string, callback: () => void): () => void;
onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void;
}
declare global {

View File

@@ -78,6 +78,35 @@ export interface LibraryScanSummary {
total: number;
}
/** Enumerates reasons why an import candidate might be skipped. */
export type ImportSkipReason = 'duplicate' | 'checksum' | 'unsupported' | 'inside-library';
/** Describes a source entry that was skipped during an import run. */
export interface ImportSkipEntry {
/** Absolute path of the skipped file. */
path: string;
/** Reason the file did not qualify for import. */
reason: ImportSkipReason;
}
/** Records a failure encountered while processing an import candidate. */
export interface ImportFailureEntry {
/** Absolute path of the problematic file or directory. */
path: string;
/** Human-readable message explaining the failure. */
message: string;
}
/** Summary returned after importing external audio sources. */
export interface LibraryImportResult {
/** Files successfully copied into the library. */
imported: AudioFileSummary[];
/** Candidates that were skipped with a known reason. */
skipped: ImportSkipEntry[];
/** Candidates that failed due to unexpected errors. */
failed: ImportFailureEntry[];
}
/**
* Shape of persisted application settings.
*/