mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 02:36:01 +10:00
1167 lines
42 KiB
TypeScript
1167 lines
42 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, LibraryScanSummary, 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';
|
|
|
|
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 }>();
|
|
|
|
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();
|
|
|
|
const cleanedTempFiles = 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 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)
|
|
const embeddedMetadata = this.tagService.readMetadata(absolutePath);
|
|
|
|
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 ?? [])
|
|
};
|
|
const upserted = this.database.upsertFile(record);
|
|
|
|
// Update custom name from embedded title if present, otherwise keep existing
|
|
const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
|
|
if (customName !== upserted.customName) {
|
|
this.database.updateCustomName(upserted.id, customName);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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), 512);
|
|
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];
|
|
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
|
|
});
|
|
|
|
// 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
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
};
|
|
|
|
// 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, '/');
|
|
}
|
|
|
|
/**
|
|
* 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 mmImport = await import('music-metadata');
|
|
const mm = (mmImport as { default?: unknown }).default ?? mmImport;
|
|
|
|
const loadMusicMetadata = typeof mm === 'object' && mm !== null && 'loadMusicMetadata' in mm
|
|
? (mm as { loadMusicMetadata: () => Promise<{ parseFile: (path: string, opts?: { duration?: boolean }) => Promise<{
|
|
format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null };
|
|
common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown };
|
|
}> }> }).loadMusicMetadata
|
|
: null;
|
|
|
|
if (!loadMusicMetadata) {
|
|
throw new Error('music-metadata loadMusicMetadata not found');
|
|
}
|
|
|
|
const musicMetadata = await loadMusicMetadata();
|
|
const metadata = await musicMetadata.parseFile(filePath, { duration: true });
|
|
const 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.');
|
|
}
|
|
}
|
|
}
|