Files
AudioSort/src/main/services/LibraryService.ts

1903 lines
70 KiB
TypeScript

import fs from 'node:fs/promises';
import { createReadStream } from 'node:fs';
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,
ImportFailureEntry,
ImportSkipEntry,
LibraryImportResult,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
} from '../../shared/models';
import { DatabaseService, FileRecordInput } from './DatabaseService';
import { SettingsService } from './SettingsService';
import { TagService } from './TagService';
import { SearchService } from './SearchService';
import { WaveFile } from 'wavefile';
import { OrganizationService } from './OrganizationService';
type MusicMetadataParser = (path: string, options?: { duration?: boolean }) => Promise<{
format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null };
common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown };
}>;
type MusicMetadataNamespace = Partial<{ parseFile: MusicMetadataParser }> & {
default?: Partial<{ parseFile: MusicMetadataParser }>;
};
interface CsvCategoryRow {
Category: string;
SubCategory: string;
CatID: string;
CatShort: string;
Explanations: string;
'Synonyms - Comma Separated': string;
}
/**
* Coordinates library scanning, categorisation, and file-level operations.
*/
export class LibraryService {
private categoriesLoaded = false;
private readonly metadataFailures = new Set<string>();
private readonly checksumFailures = new Set<string>();
private readonly organization: OrganizationService;
private metadataSuggestionCache: { authors: Set<string> } | null = null;
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
private offlineAudioContextCtor: (new (channelCount: number, length: number, sampleRate: number) => any) | null = null;
private musicMetadataParseFile: MusicMetadataParser | null = null;
public constructor(
private readonly database: DatabaseService,
private readonly settings: SettingsService,
private readonly tagService: TagService,
private readonly search: SearchService
) {
this.organization = new OrganizationService(database);
}
/**
* Parses the UCS CSV catalog and stores the categories in the database once per session.
*/
public async ensureCategoriesLoaded(csvAbsolutePath: string): Promise<void> {
if (this.categoriesLoaded) {
return;
}
const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8');
const rows = parse(fileContent, {
columns: true,
skip_empty_lines: true
}) as CsvCategoryRow[];
for (const row of rows) {
const category: CategoryRecord = {
id: row.CatID,
category: row.Category,
subCategory: row.SubCategory,
shortCode: row.CatShort,
explanation: row.Explanations,
synonyms: row['Synonyms - Comma Separated']
.split(',')
.map((value: string) => value.trim())
.filter((value: string) => value.length > 0)
};
this.database.upsertCategory(category);
}
this.categoriesLoaded = true;
}
/**
* Returns the current settings snapshot (used to expose default paths to the renderer).
*/
public getSettings(): AppSettings {
return this.settings.getSettings();
}
/**
* Triggers a library rescan and refreshes the search index.
*/
public async scanLibrary(): Promise<LibraryScanSummary> {
this.resetMetadataSuggestionsCache();
this.waveformPreviewCache.clear();
await this.cleanupTempFiles();
const libraryRoot = this.settings.ensureLibraryPath();
const existing = this.database.listFiles();
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const));
const discoveredByPath = new Map<string, AudioFileSummary>();
const pattern = ['**/*.wav', '**/*.wave'];
const absolutePaths = await fg(pattern, {
cwd: libraryRoot,
absolute: true,
onlyFiles: true,
suppressErrors: true,
caseSensitiveMatch: false
});
let added = 0;
let updated = 0;
const seen = new Set<string>();
for (const absolutePath of absolutePaths) {
const stats = await fs.stat(absolutePath);
const relativePath = path.relative(libraryRoot, absolutePath);
const fileName = path.basename(absolutePath);
const displayName = path.basename(fileName, path.extname(fileName));
const metadata = await this.extractAudioMetadata(absolutePath);
const createdAt = Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs;
const checksum = await this.computeFileChecksum(absolutePath);
const knownByPath = existingByPath.get(absolutePath);
const knownByChecksum = checksum ? existingByChecksum.get(checksum) : null;
const knownFile = knownByPath ?? knownByChecksum ?? null;
const wasKnown = knownFile !== null;
// Read embedded WAV metadata (author, copyright, rating, title, parentId)
const embeddedMetadata = this.tagService.readMetadata(absolutePath);
let parentFileId = knownFile?.parentFileId ?? null;
// First try embedded metadata parentId
if (parentFileId === null && embeddedMetadata.parentId !== undefined) {
parentFileId = embeddedMetadata.parentId;
}
// Fall back to filename pattern matching for segments
if (parentFileId === null) {
const segmentMatch = fileName.match(/^(.*)_segment\d+(\.[^.]+)$/i);
if (segmentMatch) {
const parentFileName = `${segmentMatch[1]}${segmentMatch[2]}`;
const parentAbsolutePath = path.join(path.dirname(absolutePath), parentFileName);
const parentRecord = existingByPath.get(parentAbsolutePath) ?? discoveredByPath.get(parentAbsolutePath) ?? null;
if (parentRecord) {
parentFileId = parentRecord.id;
}
}
}
const record: FileRecordInput = {
absolutePath,
relativePath,
fileName,
displayName,
modifiedAt: stats.mtimeMs,
createdAt,
size: stats.size,
durationMs: metadata.durationMs,
sampleRate: metadata.sampleRate,
bitDepth: metadata.bitDepth,
checksum,
tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []),
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []),
parentFileId
};
const upserted = this.database.upsertFile(record);
// Update custom name from embedded title if present, otherwise keep existing
const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
const finalRecord = customName !== upserted.customName
? this.database.updateCustomName(upserted.id, customName)
: upserted;
existingByPath.set(absolutePath, finalRecord);
if (checksum) {
existingByChecksum.set(checksum, finalRecord);
}
discoveredByPath.set(absolutePath, finalRecord);
if (parentFileId !== null) {
const embeddedParent = embeddedMetadata.parentId ?? null;
if (embeddedParent === null || embeddedParent !== parentFileId) {
this.tagService.writeMetadataOnly(absolutePath, {
tags: finalRecord.tags,
categories: finalRecord.categories,
title: customName ?? embeddedMetadata.title ?? finalRecord.displayName,
author: embeddedMetadata.author ?? null,
rating: embeddedMetadata.rating,
copyright: embeddedMetadata.copyright ?? null,
parentId: parentFileId
});
}
}
if (wasKnown) {
updated += 1;
} else {
added += 1;
}
seen.add(absolutePath);
}
const removed = this.database.removeFilesOutside(seen);
this.search.rebuildIndex();
this.resetMetadataSuggestionsCache();
const total = this.database.listFiles().length;
return {
added,
updated,
removed,
total
};
}
/**
* Provides the current list of library entries (already sorted by the database query).
*/
public listFiles(): AudioFileSummary[] {
return this.database.listFiles();
}
/**
* Attempts to resolve a file summary by id. Returns null when the record no longer exists.
*/
public getFileById(fileId: number): AudioFileSummary | null {
try {
return this.database.getFileById(fileId);
} catch (error) {
if (error instanceof Error) {
console.warn(`Failed to resolve file by id ${fileId}:`, error.message);
}
return null;
}
}
/**
* Returns groups of files that have identical checksums (potential duplicates).
*/
public listDuplicates(): { checksum: string; files: AudioFileSummary[] }[] {
return this.database.listDuplicateGroups();
}
/**
* Finds and cleans up orphaned .tmp files in the library directory.
* Returns the number of files cleaned up.
*/
public async cleanupTempFiles(): Promise<number> {
const libraryRoot = this.settings.getSettings().libraryPath;
if (!libraryRoot) {
return 0;
}
const pattern = ['**/*.tmp'];
const tempFiles = await fg(pattern, {
cwd: libraryRoot,
absolute: true,
onlyFiles: true,
suppressErrors: true
});
let cleanedCount = 0;
for (const tempFile of tempFiles) {
try {
// Try to determine the intended final name by removing the temp suffix pattern
const baseMatch = tempFile.match(/^(.+)\.\d+-\d+-[a-f0-9]+\.tmp$/);
if (baseMatch) {
const intendedPath = baseMatch[1];
// Check if the intended destination exists
try {
await fs.access(intendedPath);
await fs.unlink(tempFile);
cleanedCount++;
} catch {
await fs.rename(tempFile, intendedPath);
cleanedCount++;
}
} else {
console.warn(`Found temp file with unknown pattern: ${tempFile}`);
}
} catch (error) {
console.error(`Failed to clean up temp file ${tempFile}:`, error);
}
}
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.
*/
public async renameFile(fileId: number, requestedName: string): Promise<AudioFileSummary> {
const record = this.database.getFileById(fileId);
const libraryRoot = this.settings.ensureLibraryPath();
const cleanName = this.normaliseFileName(requestedName);
const targetDirectory = path.dirname(record.absolutePath);
const targetPath = path.join(targetDirectory, cleanName);
await fs.rename(record.absolutePath, targetPath);
const updated = this.database.updateFileLocation(
fileId,
targetPath,
path.relative(libraryRoot, targetPath),
path.basename(targetPath),
path.basename(targetPath, path.extname(targetPath))
);
this.waveformPreviewCache.delete(fileId);
this.search.rebuildIndex();
return updated;
}
/**
* Moves a file to a different subdirectory under the library root.
*/
public async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
const record = this.database.getFileById(fileId);
const libraryRoot = this.settings.ensureLibraryPath();
const normalisedTarget = path.resolve(libraryRoot, targetRelativeDirectory);
this.assertWithinLibrary(libraryRoot, normalisedTarget);
await fs.mkdir(normalisedTarget, { recursive: true });
const targetPath = path.join(normalisedTarget, record.fileName);
await fs.rename(record.absolutePath, targetPath);
const updated = this.database.updateFileLocation(
fileId,
targetPath,
path.relative(libraryRoot, targetPath),
path.basename(targetPath),
path.basename(targetPath, path.extname(targetPath))
);
this.waveformPreviewCache.delete(fileId);
this.search.rebuildIndex();
return updated;
}
/**
* Provides the binary payload required for renderer playback.
*/
public async getAudioBuffer(fileId: number): Promise<AudioBufferPayload> {
const record = this.database.getFileById(fileId);
const buffer = await fs.readFile(record.absolutePath);
const payload: AudioBufferPayload = {
fileId,
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
mimeType: 'audio/wav'
};
return payload;
}
/**
* Generates a lightweight waveform preview suitable for list rendering.
*/
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
const record = this.database.getFileById(fileId);
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384);
const cacheHit = this.waveformPreviewCache.get(fileId);
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
return { samples: cacheHit.samples, rms: cacheHit.rms };
}
try {
const buffer = await fs.readFile(record.absolutePath);
const wave = new WaveFile(buffer);
const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
const firstChannel = channels[0];
if (!firstChannel || firstChannel.length === 0) {
const fallback = new Array(effectivePoints).fill(0);
this.waveformPreviewCache.set(fileId, {
modifiedAt: record.modifiedAt,
pointCount: effectivePoints,
samples: fallback,
rms: 0
});
return { samples: fallback, rms: 0 };
}
const amplitudeScale = Math.max(1, this.resolveWaveformAmplitudeScale(wave));
const blockSize = Math.max(1, Math.floor(firstChannel.length / effectivePoints));
const peaks: number[] = [];
let sumSquares = 0;
let sampleTotal = 0;
for (let index = 0; index < effectivePoints; index += 1) {
const start = index * blockSize;
const end = index === effectivePoints - 1 ? firstChannel.length : Math.min(firstChannel.length, start + blockSize);
let blockPeak = 0;
for (let cursor = start; cursor < end; cursor += 1) {
for (let channelIndex = 0; channelIndex < channels.length; channelIndex += 1) {
const channel = channels[channelIndex];
const rawSample = channel?.[cursor] ?? 0;
const normalisedSample = rawSample / amplitudeScale;
const clampedSample = Math.max(-1, Math.min(1, normalisedSample));
const magnitude = Math.abs(clampedSample);
if (magnitude > blockPeak) {
blockPeak = magnitude;
}
sumSquares += clampedSample * clampedSample;
sampleTotal += 1;
}
}
peaks.push(Math.min(blockPeak, 1));
}
const samples = this.normaliseWaveformArray(peaks);
const rms = sampleTotal > 0 ? Math.sqrt(sumSquares / sampleTotal) : 0;
this.waveformPreviewCache.set(fileId, {
modifiedAt: record.modifiedAt,
pointCount: effectivePoints,
samples,
rms
});
return { samples, rms };
} catch (error) {
console.warn('Failed to generate waveform preview', { fileId, error });
const fallback = new Array(effectivePoints).fill(0);
this.waveformPreviewCache.set(fileId, {
modifiedAt: record.modifiedAt,
pointCount: effectivePoints,
samples: fallback,
rms: 0
});
return { samples: fallback, rms: 0 };
}
}
/**
* Applies tag updates, optionally re-running the organize pipeline when categories are present.
*/
public async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
const updated = this.tagService.applyTagging(payload.fileId, payload.tags, payload.categories);
this.waveformPreviewCache.delete(payload.fileId);
if (updated.categories.length > 0) {
const metadataSnapshot = this.tagService.readMetadata(updated.absolutePath);
return this.organizeFile(payload.fileId, {
customName: updated.customName ?? undefined,
author: metadataSnapshot.author?.trim() || undefined,
rating: metadataSnapshot.rating ?? undefined
});
}
this.resetMetadataSuggestionsCache();
this.search.rebuildIndex();
return updated;
}
/**
* Updates the custom name for a file without organizing it.
*/
public updateCustomName(fileId: number, customName: string | null): AudioFileSummary {
const updated = this.database.updateCustomName(fileId, customName);
this.search.rebuildIndex();
return updated;
}
/**
* Finds the next available sequence number by checking both database and filesystem.
* This prevents race conditions when multiple files are being organized simultaneously.
*/
private async findNextAvailableNumberWithFilesystemCheck(
folderPath: string,
baseName: string,
targetDirectory: string
): Promise<number> {
// Start with database conflicts
let nextIndex = this.organization.findNextAvailableNumber(folderPath, baseName);
// Keep checking filesystem until we find an available number
const maxAttempts = 100; // Prevent infinite loops
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const candidateFileName = `${baseName}_${this.organization.formatSequenceNumber(nextIndex)}.wav`;
const candidatePath = path.join(targetDirectory, candidateFileName);
try {
await fs.stat(candidatePath);
// File exists, try next number
nextIndex++;
} catch {
// File doesn't exist, this number is available
return nextIndex;
}
}
// Fallback if we somehow exhaust attempts
return nextIndex;
}
/**
* Automatically organizes a file based on its categories.
* Moves to CATEGORY/SUBCATEGORY folder and renames to SHORT_NN.wav or SHORT_customname.wav
*/
public async organizeFile(
fileId: number,
metadata: { customName?: string | null; author?: string | null; rating?: number }
): Promise<AudioFileSummary> {
const record = this.database.getFileById(fileId);
const category = this.organization.getPrimaryCategory(record);
if (!category) {
throw new Error('Cannot organize file without category assignment');
}
const libraryRoot = this.settings.ensureLibraryPath();
const folderPath = this.organization.buildFolderPath(category);
const requestedCustomName = this.normaliseMetadataInput(metadata.customName);
const existingCustomName = record.customName ?? undefined;
const effectiveCustomName = requestedCustomName !== undefined ? (requestedCustomName ?? undefined) : existingCustomName;
const baseName = this.organization.buildBaseName(category, effectiveCustomName);
const conflicts = this.organization
.listConflictingFiles(folderPath, baseName)
.filter((file) => file.id !== record.id);
const requireNumbering = !effectiveCustomName || conflicts.length > 0;
const targetDirectory = path.resolve(libraryRoot, folderPath);
this.assertWithinLibrary(libraryRoot, targetDirectory);
const currentFolder = this.normalizeRelativePath(path.dirname(record.relativePath));
const targetFolderNormalized = this.normalizeRelativePath(folderPath);
const needsDirectoryChange = currentFolder !== targetFolderNormalized;
let targetFileName: string;
if (requireNumbering) {
const pattern = new RegExp(`^${baseName}_(\\d+)\\.wav$`, 'i');
if (!needsDirectoryChange && pattern.test(record.fileName)) {
targetFileName = record.fileName;
} else {
const nextIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
targetFileName = `${baseName}_${this.organization.formatSequenceNumber(nextIndex)}.wav`;
}
} else {
targetFileName = `${baseName}.wav`;
}
const needsRename = record.fileName !== targetFileName;
const needsMove = needsDirectoryChange || needsRename;
let targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
let targetPath = path.join(targetDirectory, targetFileName);
// Final safety check: if target exists and is not our source, force numbering
if (needsMove) {
const targetExists = await this.pathExists(targetPath);
if (targetExists) {
const targetNormalized = this.normalizeAbsolutePath(targetPath);
const sourceNormalized = this.normalizeAbsolutePath(record.absolutePath);
if (targetNormalized !== sourceNormalized) {
const safeIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
targetFileName = `${baseName}_${this.organization.formatSequenceNumber(safeIndex)}.wav`;
targetPath = path.join(targetDirectory, targetFileName);
targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
}
}
}
if (!needsMove) {
const nextCustomName = effectiveCustomName ?? null;
let updatedRecord = record;
if (nextCustomName !== (record.customName ?? null)) {
updatedRecord = this.database.updateCustomName(fileId, nextCustomName);
}
// Read existing metadata from the WAV file
const existing = this.tagService.readMetadata(updatedRecord.absolutePath);
// Merge with new metadata (only update fields that are provided)
const requestedAuthor = this.normaliseMetadataInput(metadata.author);
const mergedAuthor = requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null;
const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
this.tagService.writeMetadataOnly(updatedRecord.absolutePath, {
tags: updatedRecord.tags,
categories: updatedRecord.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updatedRecord.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
this.updateMetadataSuggestionsCache(mergedAuthor);
}
this.resetMetadataSuggestionsCache();
this.waveformPreviewCache.delete(fileId);
this.search.rebuildIndex();
return updatedRecord;
}
if (requireNumbering && conflicts.length > 0) {
// Filter out conflicts that already have proper numbered names in the target directory
const pattern = new RegExp(`^${baseName}_(\\d+)\\.wav$`, 'i');
const conflictsNeedingRenumber = conflicts.filter((conflict) => {
const conflictFolder = this.normalizeRelativePath(path.dirname(conflict.relativePath));
const isInTargetFolder = conflictFolder === targetFolderNormalized;
const hasNumberedName = pattern.test(conflict.fileName);
return !isInTargetFolder || !hasNumberedName;
});
if (conflictsNeedingRenumber.length > 0) {
await this.renumberConflictingFiles(conflictsNeedingRenumber, folderPath, baseName, libraryRoot);
const nextIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
targetFileName = `${baseName}_${this.organization.formatSequenceNumber(nextIndex)}.wav`;
targetPath = path.join(targetDirectory, targetFileName);
targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
}
}
await fs.mkdir(targetDirectory, { recursive: true });
await fs.rename(record.absolutePath, targetPath);
// Try to update database, if UNIQUE constraint fails, retry with numbering
let updated: AudioFileSummary;
try {
updated = this.database.updateFileLocation(
fileId,
targetPath,
targetRelativePath,
targetFileName,
path.basename(targetFileName, '.wav')
);
} catch (error: unknown) {
// Check if this is a UNIQUE constraint error on absolute_path
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('UNIQUE constraint') && errorMessage.includes('absolute_path')) {
await fs.rename(targetPath, record.absolutePath);
const safeIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
targetFileName = `${baseName}_${this.organization.formatSequenceNumber(safeIndex)}.wav`;
targetPath = path.join(targetDirectory, targetFileName);
targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
await fs.rename(record.absolutePath, targetPath);
updated = this.database.updateFileLocation(
fileId,
targetPath,
targetRelativePath,
targetFileName,
path.basename(targetFileName, '.wav')
);
} else {
console.error(`Unexpected error during updateFileLocation:`, errorMessage);
throw error;
}
}
const nextCustomName = effectiveCustomName ?? null;
if (nextCustomName !== (record.customName ?? null)) {
updated = this.database.updateCustomName(fileId, nextCustomName);
}
// Read existing metadata from the file at its new location
const existing = this.tagService.readMetadata(targetPath);
// Merge with new metadata (only update fields that are provided)
const movedAuthor = this.normaliseMetadataInput(metadata.author);
const mergedAuthor = movedAuthor !== undefined ? movedAuthor : existing.author ?? null;
const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
// Write tags, categories, and additional metadata to the organized file
this.tagService.writeMetadataOnly(targetPath, {
tags: updated.tags,
categories: updated.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updated.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
this.updateMetadataSuggestionsCache(mergedAuthor);
}
this.resetMetadataSuggestionsCache();
this.waveformPreviewCache.delete(fileId);
this.search.rebuildIndex();
return updated;
}
/**
* Deletes files from disk and removes them from the database.
*/
public async deleteFiles(fileIds: number[]): Promise<void> {
for (const fileId of fileIds) {
const record = this.database.getFileById(fileId);
try {
await fs.unlink(record.absolutePath);
} catch (error) {
console.error(`Failed to delete file ${record.absolutePath}:`, error);
}
this.database.deleteFile(fileId);
this.waveformPreviewCache.delete(fileId);
}
this.resetMetadataSuggestionsCache();
this.search.rebuildIndex();
}
/**
* Splits an audio file into multiple segments, writing each segment to disk and registering it in the library.
*/
public async splitFile(fileId: number, requestSegments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
if (!Array.isArray(requestSegments) || requestSegments.length === 0) {
return [];
}
const record = this.database.getFileById(fileId);
const fileBuffer = await fs.readFile(record.absolutePath);
const wave = new WaveFile(fileBuffer);
const format = (wave as WaveFile & { fmt?: { sampleRate?: number; bitsPerSample?: number } }).fmt;
const rawSamples = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
const channels = Array.isArray(rawSamples) ? rawSamples : [rawSamples];
if (channels.length === 0 || channels[0].length === 0) {
throw new Error('Unable to split file: no audio data available.');
}
const sampleRate = format?.sampleRate ?? record.sampleRate ?? null;
if (!sampleRate) {
throw new Error('Unable to split file: missing sample rate information.');
}
const sourceSampleCount = channels[0].length;
const totalDurationMs = Math.round((sourceSampleCount / sampleRate) * 1000);
const bitDepthNumeric = (() => {
const explicit = format?.bitsPerSample;
if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0) {
return explicit;
}
if (typeof record.bitDepth === 'number' && Number.isFinite(record.bitDepth) && record.bitDepth > 0) {
return record.bitDepth;
}
const parsedFromText = Number.parseInt(typeof wave.bitDepth === 'string' ? wave.bitDepth : '', 10);
return Number.isFinite(parsedFromText) && parsedFromText > 0 ? parsedFromText : null;
})();
const bitDepthText = (() => {
if (typeof wave.bitDepth === 'string' && wave.bitDepth.trim().length > 0) {
return wave.bitDepth.trim();
}
if (typeof bitDepthNumeric === 'number') {
return bitDepthNumeric.toString();
}
return '16';
})();
const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';
let originalMetadata: { author?: string | null; rating?: number; title?: string | null; copyright?: string | null } = {};
try {
const metadata = this.tagService.readMetadata(record.absolutePath);
originalMetadata = {
author: metadata.author ?? null,
rating: metadata.rating ?? undefined,
title: metadata.title ?? null,
copyright: metadata.copyright ?? null
};
} catch (error) {
console.warn('Failed to read original metadata before splitting', error);
}
const normalizedSegments = requestSegments
.map((segment) => {
const startMs = Number.isFinite(segment.startMs) ? Math.max(0, Math.min(Math.floor(segment.startMs), totalDurationMs)) : 0;
const endMs = Number.isFinite(segment.endMs) ? Math.max(0, Math.min(Math.floor(segment.endMs), totalDurationMs)) : startMs;
const safeEnd = Math.max(startMs + 1, endMs);
return {
startMs,
endMs: safeEnd,
label: segment.label?.trim() ?? undefined,
metadata: segment.metadata,
fadeInMs: Number.isFinite(segment.fadeInMs) ? Math.max(0, Math.floor(segment.fadeInMs!)) : 0,
fadeOutMs: Number.isFinite(segment.fadeOutMs) ? Math.max(0, Math.floor(segment.fadeOutMs!)) : 0
};
})
.filter((segment) => segment.endMs - segment.startMs >= 5)
.sort((a, b) => a.startMs - b.startMs);
if (normalizedSegments.length === 0) {
return [];
}
const libraryRoot = this.settings.ensureLibraryPath();
const sourceDirectory = path.dirname(record.absolutePath);
const relativeFolder = path.dirname(record.relativePath);
const folderForJoin = relativeFolder === '.' ? '' : relativeFolder;
const baseName = path.basename(record.fileName, path.extname(record.fileName));
const usedNames = new Set<string>();
let sequence = 1;
const created: AudioFileSummary[] = [];
for (const segment of normalizedSegments) {
const startSample = Math.max(0, Math.min(Math.floor((segment.startMs / 1000) * sampleRate), sourceSampleCount - 1));
const endSample = Math.max(
startSample + 1,
Math.min(Math.floor((segment.endMs / 1000) * sampleRate), sourceSampleCount)
);
if (endSample <= startSample) {
continue;
}
let segmentChannels: Float64Array[] = channels.map((channel) => channel.slice(startSample, endSample));
const fadeInMs = segment.fadeInMs ?? 0;
const fadeOutMs = segment.fadeOutMs ?? 0;
if (fadeInMs > 0 || fadeOutMs > 0) {
segmentChannels = (await this.applyFadeEnvelopeToSegment(segmentChannels, sampleRate, fadeInMs, fadeOutMs)) as Float64Array[];
}
const nextWave = new WaveFile();
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
(nextWave as WaveFile & { container?: string }).container = container;
const segmentBytes = Buffer.from(nextWave.toBuffer());
// Determine the segment name part - use label if available, otherwise use sequence
let segmentNamePart = '';
if (segment.label) {
const sanitized = this.organization.sanitizeCustomName(segment.label);
if (sanitized) {
segmentNamePart = sanitized;
}
}
let segmentFileName = '';
let segmentAbsolutePath = '';
for (let attempt = 0; attempt < 1000; attempt += 1) {
let candidateName: string;
if (segmentNamePart) {
// Use label-based name, with optional suffix for duplicates
if (attempt === 0) {
candidateName = `${baseName}_${segmentNamePart}.wav`;
} else {
candidateName = `${baseName}_${segmentNamePart}_${this.organization.formatSequenceNumber(attempt)}.wav`;
}
} else {
// Fallback to sequential numbering
const suffix = this.organization.formatSequenceNumber(sequence);
sequence += 1;
candidateName = `${baseName}_segment${suffix}.wav`;
}
if (usedNames.has(candidateName)) {
continue;
}
const candidatePath = path.join(sourceDirectory, candidateName);
const exists = await this.pathExists(candidatePath);
if (exists) {
continue;
}
segmentFileName = candidateName;
segmentAbsolutePath = candidatePath;
usedNames.add(candidateName);
break;
}
if (!segmentFileName || !segmentAbsolutePath) {
throw new Error('Failed to allocate filename for split segment.');
}
this.assertWithinLibrary(libraryRoot, segmentAbsolutePath);
await fs.writeFile(segmentAbsolutePath, segmentBytes);
const stats = await fs.stat(segmentAbsolutePath);
const checksum = createHash('md5').update(segmentBytes).digest('hex');
const relativePath = this.toLibraryRelativePath(folderForJoin, segmentFileName);
const durationMs = Math.round(((endSample - startSample) / sampleRate) * 1000);
const resolvedTagsSource = segment.metadata?.tags ?? record.tags;
const resolvedCategoriesSource = segment.metadata?.categories ?? record.categories;
const resolvedTags = Array.isArray(resolvedTagsSource) ? resolvedTagsSource.slice() : record.tags.slice();
const resolvedCategories = Array.isArray(resolvedCategoriesSource)
? resolvedCategoriesSource.slice()
: record.categories.slice();
const fileRecord = this.database.upsertFile({
absolutePath: segmentAbsolutePath,
relativePath,
fileName: segmentFileName,
displayName: path.basename(segmentFileName, '.wav'),
modifiedAt: stats.mtimeMs,
createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
size: stats.size,
durationMs,
sampleRate,
bitDepth: bitDepthNumeric,
checksum,
tags: resolvedTags,
categories: resolvedCategories,
parentFileId: record.id
});
const resolvedCustomName = segment.metadata?.customName !== undefined
? this.normaliseMetadataInput(segment.metadata.customName)
: segment.label !== undefined
? this.normaliseMetadataInput(segment.label)
: record.customName ?? null;
const updatedRecord = resolvedCustomName !== fileRecord.customName
? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
: fileRecord;
const resolvedAuthor = segment.metadata?.author !== undefined
? this.normaliseMetadataInput(segment.metadata.author)
: this.normaliseMetadataInput(originalMetadata.author ?? null);
const resolvedRating = segment.metadata?.rating !== undefined
? segment.metadata.rating ?? undefined
: originalMetadata.rating;
this.tagService.writeMetadataOnly(segmentAbsolutePath, {
tags: resolvedTags,
categories: resolvedCategories,
title: resolvedCustomName ?? updatedRecord.displayName,
author: resolvedAuthor ?? undefined,
rating: resolvedRating,
copyright: originalMetadata.copyright ?? null,
parentId: record.id
});
if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
this.updateMetadataSuggestionsCache(resolvedAuthor);
}
this.waveformPreviewCache.delete(updatedRecord.id);
created.push(updatedRecord);
}
this.waveformPreviewCache.delete(record.id);
this.resetMetadataSuggestionsCache();
this.search.rebuildIndex();
return created;
}
/**
* Applies the configured fade envelope to the provided channel data. Tries to render using
* `standardized-audio-context` for consistency with the editor preview and falls back to
* a manual smooth-step implementation if the dependency is unavailable at runtime.
*/
private async applyFadeEnvelopeToSegment(
source: Float64Array[],
sampleRate: number,
fadeInMs: number,
fadeOutMs: number
): Promise<Float64Array[]> {
if (source.length === 0 || source[0]?.length === 0) {
return source.map((channel) => channel.slice());
}
const totalSamples = source[0].length;
const fadeInSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeInMs / 1000) * sampleRate)));
const fadeOutSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeOutMs / 1000) * sampleRate)));
if (fadeInSamples === 0 && fadeOutSamples === 0) {
return source.map((channel) => channel.slice());
}
const applyManual = (): Float64Array[] => {
return source.map((channel) => {
const copy = channel.slice();
const sampleCount = copy.length;
const fadeInDenominator = Math.max(1, fadeInSamples - 1);
const fadeOutDenominator = Math.max(1, fadeOutSamples - 1);
for (let index = 0; index < sampleCount; index += 1) {
let gain = 1;
if (fadeInSamples > 0 && index < fadeInSamples) {
const t = fadeInDenominator > 0 ? index / fadeInDenominator : 0;
const eased = this.smoothStep(t);
gain = Math.min(gain, eased);
}
if (fadeOutSamples > 0) {
const fromEnd = sampleCount - 1 - index;
if (fromEnd < fadeOutSamples) {
const t = fadeOutDenominator > 0 ? fromEnd / fadeOutDenominator : 0;
const eased = this.smoothStep(t);
gain = Math.min(gain, eased);
}
}
copy[index] = copy[index] * gain;
}
return copy;
});
};
try {
let OfflineContextCtor = this.offlineAudioContextCtor;
if (!OfflineContextCtor) {
const audioModule = (await import('standardized-audio-context')) as {
OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any;
default?: { OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any };
};
const resolvedCtor = audioModule.OfflineAudioContext ?? audioModule.default?.OfflineAudioContext ?? null;
if (typeof resolvedCtor !== 'function') {
return applyManual();
}
this.offlineAudioContextCtor = resolvedCtor;
OfflineContextCtor = resolvedCtor;
}
const channelCount = source.length;
const offlineContext = new OfflineContextCtor(channelCount, totalSamples, sampleRate);
const buffer = offlineContext.createBuffer(channelCount, totalSamples, sampleRate);
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const channelData = buffer.getChannelData(channelIndex);
const sourceChannel = source[channelIndex];
for (let sampleIndex = 0; sampleIndex < totalSamples; sampleIndex += 1) {
channelData[sampleIndex] = sourceChannel?.[sampleIndex] ?? 0;
}
}
const bufferSource = offlineContext.createBufferSource();
bufferSource.buffer = buffer;
const gainNode = offlineContext.createGain();
bufferSource.connect(gainNode);
gainNode.connect(offlineContext.destination);
const totalDurationSeconds = totalSamples / sampleRate;
gainNode.gain.setValueAtTime(1, 0);
if (fadeInSamples > 0) {
const fadeInCurve = this.generateFadeCurve(Math.max(fadeInSamples, 2), 'in');
gainNode.gain.setValueAtTime(fadeInCurve[0], 0);
gainNode.gain.setValueCurveAtTime(fadeInCurve, 0, fadeInSamples / sampleRate);
gainNode.gain.setValueAtTime(1, fadeInSamples / sampleRate);
}
if (fadeOutSamples > 0) {
const fadeOutCurve = this.generateFadeCurve(Math.max(fadeOutSamples, 2), 'out');
const fadeOutStart = Math.max(0, totalDurationSeconds - fadeOutSamples / sampleRate);
gainNode.gain.setValueAtTime(1, fadeOutStart);
gainNode.gain.setValueCurveAtTime(fadeOutCurve, fadeOutStart, fadeOutSamples / sampleRate);
gainNode.gain.setValueAtTime(fadeOutCurve[fadeOutCurve.length - 1], totalDurationSeconds);
}
bufferSource.start(0);
const renderedBuffer = await offlineContext.startRendering();
const processed: Float64Array[] = [];
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const renderedChannel = renderedBuffer.getChannelData(channelIndex);
const clone = new Float64Array(renderedChannel.length);
clone.set(renderedChannel);
processed.push(clone);
}
return processed;
} catch (error) {
console.warn('Falling back to manual fade processing', error);
return applyManual();
}
}
/**
* Generates a smooth fade curve using a cubic smooth-step easing function.
*/
private generateFadeCurve(sampleCount: number, direction: 'in' | 'out'): Float32Array {
const totalSamples = Math.max(sampleCount, 2);
const curve = new Float32Array(totalSamples);
const lastIndex = totalSamples - 1;
for (let index = 0; index < totalSamples; index += 1) {
const t = lastIndex === 0 ? 1 : index / lastIndex;
const eased = this.smoothStep(Math.min(Math.max(t, 0), 1));
curve[index] = direction === 'in' ? eased : 1 - eased;
}
if (direction === 'in') {
curve[0] = 0;
curve[lastIndex] = 1;
} else {
curve[0] = 1;
curve[lastIndex] = 0;
}
return curve;
}
/**
* Computes the cubic smooth-step easing value for the provided normalised progress.
*/
private smoothStep(t: number): number {
const clamped = Math.min(Math.max(t, 0), 1);
return clamped * clamped * (3 - 2 * clamped);
}
/**
* Returns the previously parsed UCS category catalog.
*/
public listCategories(): CategoryRecord[] {
return this.database.listCategories();
}
/**
* Opens the containing folder for a file in the system file explorer.
*/
public async openFileFolder(fileId: number): Promise<void> {
const record = this.database.getFileById(fileId);
const { shell } = await import('electron');
shell.showItemInFolder(record.absolutePath);
}
/**
* Reads embedded metadata from a WAV file and returns author, title, and rating.
*/
public async readFileMetadata(fileId: number): Promise<{ author?: string; title?: string; rating?: number }> {
const record = this.database.getFileById(fileId);
const metadata = this.tagService.readMetadata(record.absolutePath);
this.updateMetadataSuggestionsCache(metadata.author);
return metadata;
}
/**
* Updates metadata (author, rating) without organizing the file.
* Only writes to the WAV INFO chunk, doesn't move or rename the file.
*/
public async updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void> {
const record = this.database.getFileById(fileId);
// Read existing metadata from the WAV file
const existing = this.tagService.readMetadata(record.absolutePath);
// Merge with new metadata (only update fields that are provided)
const requestedAuthor = this.normaliseMetadataInput(metadata.author);
const mergedMetadata = {
tags: record.tags,
categories: record.categories,
title: record.customName ?? existing.title,
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
rating: metadata.rating !== undefined ? metadata.rating : existing.rating,
copyright: existing.copyright ?? null,
parentId: record.parentFileId ?? null
};
// Write merged metadata to the WAV file
this.tagService.writeMetadataOnly(record.absolutePath, mergedMetadata);
// Update suggestions cache
if (typeof mergedMetadata.author === 'string' && mergedMetadata.author.length > 0) {
this.updateMetadataSuggestionsCache(mergedMetadata.author);
}
this.waveformPreviewCache.delete(fileId);
this.resetMetadataSuggestionsCache();
}
/**
* Returns distinct metadata suggestions aggregated from the library.
*/
public async listMetadataSuggestions(): Promise<{ authors: string[] }> {
if (!this.metadataSuggestionCache) {
const authors = new Set<string>();
const files = this.database.listFiles();
for (const file of files) {
try {
const metadata = this.tagService.readMetadata(file.absolutePath);
if (metadata.author) {
this.addSuggestionValue(authors, metadata.author);
}
} catch (error) {
if (!this.metadataFailures.has(file.absolutePath)) {
this.metadataFailures.add(file.absolutePath);
// eslint-disable-next-line no-console -- Provide visibility once per file for suggestion harvesting issues.
console.warn(`Failed to collect metadata suggestions from ${file.absolutePath}`, error);
}
}
}
this.metadataSuggestionCache = { authors };
}
return {
authors: Array.from(this.metadataSuggestionCache.authors).sort((a, b) => a.localeCompare(b))
};
}
private addSuggestionValue(target: Set<string>, value: string): void {
const trimmed = value.trim();
if (trimmed.length > 0) {
target.add(trimmed);
}
}
/**
* Normalises optional metadata inputs so blank strings collapse to null while preserving undefined for untouched fields.
*/
private normaliseMetadataInput(value: string | null | undefined): string | null | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private updateMetadataSuggestionsCache(author?: string | null): void {
if (!this.metadataSuggestionCache) {
return;
}
if (author) {
this.addSuggestionValue(this.metadataSuggestionCache.authors, author);
}
}
/**
* Drops the in-memory metadata suggestion cache so the next read reflects the current library state.
*/
private resetMetadataSuggestionsCache(): void {
this.metadataSuggestionCache = null;
}
private async renumberConflictingFiles(
conflicts: AudioFileSummary[],
folderPath: string,
baseName: string,
libraryRoot: string
): Promise<void> {
if (conflicts.length === 0) {
return;
}
const targetDirectory = path.resolve(libraryRoot, folderPath);
// Get fresh file records from database in case they were updated by previous operations
const freshConflicts = conflicts.map(c => this.database.getFileById(c.id));
console.log(`[renumberConflictingFiles] planning renumber for ${freshConflicts.length} conflict(s) in ${folderPath}`);
const sortedConflicts = freshConflicts
.slice()
.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { numeric: true, sensitivity: 'base' }));
const plans = sortedConflicts.map((file, index) => {
const sequence = this.organization.formatSequenceNumber(index + 1);
const finalFileName = `${baseName}_${sequence}.wav`;
const finalAbsolutePath = path.join(targetDirectory, finalFileName);
const relativePath = this.toLibraryRelativePath(folderPath, finalFileName);
const tempPath = `${finalAbsolutePath}.${Date.now()}-${file.id}-${Math.random().toString(16).slice(2)}.tmp`;
return {
file,
tempPath,
finalAbsolutePath,
relativePath,
fileName: finalFileName,
displayName: path.basename(finalFileName, '.wav')
};
});
const movedToTemp: typeof plans = [];
try {
for (const plan of plans) {
await fs.rename(plan.file.absolutePath, plan.tempPath);
movedToTemp.push(plan);
}
for (const plan of plans) {
await fs.rename(plan.tempPath, plan.finalAbsolutePath);
this.database.updateFileLocation(
plan.file.id,
plan.finalAbsolutePath,
plan.relativePath,
plan.fileName,
plan.displayName
);
}
} catch (error) {
console.error('Error during batch rename, attempting rollback:', error);
for (const plan of movedToTemp) {
try {
await fs.access(plan.tempPath);
await fs.rename(plan.tempPath, plan.file.absolutePath);
} catch (rollbackError) {
console.error(`Failed to rollback ${plan.tempPath}:`, rollbackError);
try {
await fs.rename(plan.tempPath, plan.finalAbsolutePath);
} catch {
// Recovery failed, file may be lost
}
}
}
throw error;
}
}
private toLibraryRelativePath(folderPath: string, fileName: string): string {
return this.normalizeRelativePath(path.join(folderPath, fileName));
}
private normalizeAbsolutePath(value: string): string {
return path.resolve(value).replace(/\\/g, '/');
}
private async pathExists(targetPath: string): Promise<boolean> {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
/**
* Determines the divisor required to normalise raw sample amplitudes for the given wave file.
*/
private resolveWaveformAmplitudeScale(wave: WaveFile): number {
const bitDepthText = typeof wave.bitDepth === 'string' ? wave.bitDepth.trim() : '';
if (bitDepthText.toLowerCase().includes('f')) {
return 1;
}
const parsedBitDepth = Number.parseInt(bitDepthText, 10);
if (Number.isFinite(parsedBitDepth) && parsedBitDepth > 0) {
return 2 ** (parsedBitDepth - 1);
}
const fmtDepth = (wave as WaveFile & { fmt?: { bitsPerSample?: number } }).fmt?.bitsPerSample;
if (typeof fmtDepth === 'number' && Number.isFinite(fmtDepth) && fmtDepth > 0) {
return 2 ** (fmtDepth - 1);
}
return 1;
}
/**
* Scales an array of sample peak magnitudes into the 0..1 range while guarding against NaN values.
*/
private normaliseWaveformArray(values: number[]): number[] {
let peak = 0;
for (const value of values) {
if (Number.isFinite(value) && value > peak) {
peak = value;
}
}
if (peak <= 0) {
return values.map(() => 0);
}
return values.map((value) => {
if (!Number.isFinite(value)) {
return 0;
}
const normalised = value / peak;
if (normalised <= 0) {
return 0;
}
if (normalised >= 1) {
return 1;
}
return normalised;
});
}
private normalizeRelativePath(value: string): string {
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.
*/
private async extractAudioMetadata(filePath: string): Promise<{
durationMs: number | null;
sampleRate: number | null;
bitDepth: number | null;
tags: string[];
categories: string[];
}> {
try {
const parseFile = await this.resolveMusicMetadataParser();
const metadata = await parseFile(filePath, { duration: true });
const infoTags = this.tagService.readInfoTags(filePath);
const splitInfoValues = (input: string | undefined): string[] =>
input
? input
.split(/[,;]+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [];
const duration = metadata.format.duration ? Math.round(metadata.format.duration * 1000) : null;
const sampleRate = metadata.format.sampleRate ?? null;
const bitDepth = metadata.format.bitsPerSample ?? null;
const comment = (metadata.common.comment ?? [])
.map((entry: unknown) => {
if (typeof entry === 'string') {
return entry;
}
if (entry && typeof entry === 'object' && 'text' in entry) {
const value = (entry as { text?: unknown }).text;
return typeof value === 'string' ? value : '';
}
return '';
})
.filter((value: string) => value.length > 0);
const genre = (metadata.common.genre ?? [])
.map((entry: unknown) => (typeof entry === 'string' ? entry : ''))
.filter((entry: string) => entry.length > 0);
const infoTagValues = splitInfoValues(infoTags.IKEY);
const tagSet = new Set<string>();
if (infoTagValues.length > 0) {
for (const entry of infoTagValues) {
tagSet.add(entry);
}
} else {
for (const entry of comment) {
tagSet.add(entry);
}
for (const entry of genre) {
tagSet.add(entry);
}
}
// Extract subtitle field and use it as CatID for category matching
const subtitle = metadata.common.subtitle;
const categoriesSet = new Set<string>();
const subtitleValues = typeof subtitle === 'string' ? splitInfoValues(subtitle) : [];
for (const value of subtitleValues) {
categoriesSet.add(value);
}
const subjectValues = splitInfoValues(infoTags.ISBJ);
for (const value of subjectValues) {
categoriesSet.add(value);
}
if (categoriesSet.size === 0) {
const legacyGenreValues = splitInfoValues(infoTags.IGNR);
for (const value of legacyGenreValues) {
categoriesSet.add(value);
}
}
const categories: string[] = [];
for (const candidate of categoriesSet) {
const category = this.database.getCategoryById(candidate);
if (category) {
categories.push(candidate);
}
}
const fallback = await this.extractWaveFallback(filePath, {
durationMs: duration,
sampleRate,
bitDepth
});
const filteredTags = Array.from(tagSet).filter((entry) => !categories.includes(entry));
return {
durationMs: fallback.durationMs,
sampleRate: fallback.sampleRate,
bitDepth: fallback.bitDepth,
tags: filteredTags,
categories
};
} catch (error) {
if (!this.metadataFailures.has(filePath)) {
this.metadataFailures.add(filePath);
// eslint-disable-next-line no-console -- Provide visibility once per file for metadata parsing issues.
console.warn(`Failed to parse metadata for ${filePath}`, error);
}
const fallback = await this.extractWaveFallback(filePath);
return {
durationMs: fallback.durationMs,
sampleRate: fallback.sampleRate,
bitDepth: fallback.bitDepth,
tags: [],
categories: []
};
}
}
/**
* Attempts to parse metadata directly from the WAV container when the primary parser misses fields.
*/
private async extractWaveFallback(
filePath: string,
baseline?: {
durationMs: number | null;
sampleRate: number | null;
bitDepth: number | null;
}
): Promise<{
durationMs: number | null;
sampleRate: number | null;
bitDepth: number | null;
}> {
const current = {
durationMs: baseline?.durationMs ?? null,
sampleRate: baseline?.sampleRate ?? null,
bitDepth: baseline?.bitDepth ?? null
};
if (current.durationMs !== null && current.sampleRate !== null && current.bitDepth !== null) {
return current;
}
try {
const buffer = await fs.readFile(filePath);
const wave = new WaveFile(buffer);
const fmt = wave.fmt as {
sampleRate?: number;
bitsPerSample?: number;
blockAlign?: number;
};
const data = wave.data as {
chunkSize?: number;
};
const sampleRate = current.sampleRate !== null ? current.sampleRate : typeof fmt.sampleRate === 'number' ? fmt.sampleRate : null;
const bitDepth = current.bitDepth !== null ? current.bitDepth : typeof fmt.bitsPerSample === 'number' ? fmt.bitsPerSample : null;
let durationMs = current.durationMs;
const blockAlign = typeof fmt.blockAlign === 'number' ? fmt.blockAlign : null;
const chunkSize = typeof data.chunkSize === 'number' ? data.chunkSize : null;
if (durationMs === null && sampleRate && blockAlign && blockAlign > 0 && chunkSize !== null && sampleRate > 0) {
const totalSamples = chunkSize / blockAlign;
durationMs = Math.round((totalSamples / sampleRate) * 1000);
}
return {
durationMs,
sampleRate,
bitDepth
};
} catch (error) {
if (!this.metadataFailures.has(`${filePath}::wave`)) {
this.metadataFailures.add(`${filePath}::wave`);
// eslint-disable-next-line no-console -- Provide visibility once per file for metadata fallback issues.
console.warn(`Failed to parse WAV metadata for ${filePath}`, error);
}
return current;
}
}
/**
* Computes checksum from audio data only, excluding metadata chunks for stability.
*/
private async computeFileChecksum(filePath: string): Promise<string | null> {
try {
const buffer = await fs.readFile(filePath);
const wave = new WaveFile(buffer);
// Hash only the raw audio samples, not metadata or other chunks
const hash = createHash('md5');
const samples = wave.getSamples();
if (Array.isArray(samples)) {
// Multi-channel: hash each channel
for (const channel of samples) {
hash.update(Buffer.from(channel.buffer));
}
} else {
// Single channel
hash.update(Buffer.from(samples.buffer));
}
return hash.digest('hex');
} catch (error) {
if (!this.checksumFailures.has(filePath)) {
this.checksumFailures.add(filePath);
// eslint-disable-next-line no-console -- Only log checksum issues once per file.
console.warn(`Failed to compute checksum for ${filePath}`, error);
}
return null;
}
}
/**
* Normalises file names, ensuring the .wav extension is present.
*/
private normaliseFileName(input: string): string {
const trimmed = input.trim();
if (trimmed.length === 0) {
throw new Error('File name cannot be empty.');
}
const extension = path.extname(trimmed);
if (!extension) {
return `${trimmed}.wav`;
}
if (extension.toLowerCase() !== '.wav') {
throw new Error('Only WAV files are supported.');
}
return trimmed;
}
/**
* Ensures paths stay inside the configured library root.
*/
private assertWithinLibrary(libraryRoot: string, candidate: string): void {
const normalisedRoot = path.resolve(libraryRoot);
const normalisedCandidate = path.resolve(candidate);
const rootWithSeparator = normalisedRoot.endsWith(path.sep)
? normalisedRoot
: `${normalisedRoot}${path.sep}`;
if (
normalisedCandidate !== normalisedRoot &&
!normalisedCandidate.toLowerCase().startsWith(rootWithSeparator.toLowerCase())
) {
throw new Error('Target path must remain inside the configured library.');
}
}
}