mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 10:46:03 +10:00
feat: enhance waveform editor with playback controls and segment interaction
- Added onPlayFromCursor and playbackCursorMs props to WaveformEditorCanvas for playback control. - Implemented visual feedback for segment selection and hover states, including color adjustments and labels. - Introduced smooth cursor animation for better user experience during interaction. - Enhanced segment metadata handling in types and IPC for better integration with the library. - Added focusOnFile method in LibraryStore to manage file selection and refresh. - Updated styles for edit mode, including new classes for waveform and playback controls. - Introduced new IPC channel for splitting audio files and updated related interfaces. - Added parentFileId to AudioFileSummary for tracking source files. - Implemented tests for tagging service to ensure metadata integrity and tag preservation. - Created scrubWorklet and assets type definitions for future audio processing features.
This commit is contained in:
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, Menu } from 'electron';
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/ipc';
|
||||
import { TagUpdatePayload } from '../shared/models';
|
||||
import { SplitSegmentRequest, TagUpdatePayload } from '../shared/models';
|
||||
import { DatabaseService } from './services/DatabaseService';
|
||||
import { LibraryService } from './services/LibraryService';
|
||||
import { SearchService } from './services/SearchService';
|
||||
@@ -141,6 +141,7 @@ export class MainApp {
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.librarySplit);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
|
||||
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
|
||||
@@ -241,7 +242,11 @@ export class MainApp {
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryOrganize,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) =>
|
||||
async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
fileId: number,
|
||||
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
|
||||
) =>
|
||||
this.requireLibrary().organizeFile(fileId, metadata)
|
||||
);
|
||||
|
||||
@@ -259,6 +264,12 @@ export class MainApp {
|
||||
this.requireLibrary().deleteFiles(fileIds)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.librarySplit,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, segments: SplitSegmentRequest[]) =>
|
||||
this.requireLibrary().splitFile(fileId, segments)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.libraryBuffer, async (_event: IpcMainInvokeEvent, fileId: number) =>
|
||||
this.requireLibrary().getAudioBuffer(fileId)
|
||||
);
|
||||
@@ -285,7 +296,11 @@ export class MainApp {
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryUpdateMetadata,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) =>
|
||||
async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
fileId: number,
|
||||
metadata: { author?: string | null; copyright?: string | null; rating?: number }
|
||||
) =>
|
||||
this.requireLibrary().updateFileMetadata(fileId, metadata)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface FileRecordInput {
|
||||
tags?: string[];
|
||||
/** Optional category payload (stored as JSON string). */
|
||||
categories?: string[];
|
||||
/** Optional parent file reference when generated from another file. */
|
||||
parentFileId?: number | null;
|
||||
}
|
||||
|
||||
export interface FileRecordRow extends AudioFileSummary {}
|
||||
@@ -89,7 +91,8 @@ export class DatabaseService {
|
||||
bit_depth,
|
||||
checksum,
|
||||
tags_json,
|
||||
categories_json
|
||||
categories_json,
|
||||
parent_file_id
|
||||
) VALUES (
|
||||
@absolutePath,
|
||||
@relativePath,
|
||||
@@ -103,7 +106,8 @@ export class DatabaseService {
|
||||
@bitDepth,
|
||||
@checksum,
|
||||
@tagsJson,
|
||||
@categoriesJson
|
||||
@categoriesJson,
|
||||
@parentFileId
|
||||
)
|
||||
ON CONFLICT(absolute_path) DO UPDATE SET
|
||||
library_relative_path = excluded.library_relative_path,
|
||||
@@ -117,7 +121,8 @@ export class DatabaseService {
|
||||
bit_depth = excluded.bit_depth,
|
||||
checksum = excluded.checksum,
|
||||
tags_json = CASE WHEN files.tags_json = '[]' THEN excluded.tags_json ELSE files.tags_json END,
|
||||
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END
|
||||
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END,
|
||||
parent_file_id = CASE WHEN excluded.parent_file_id IS NOT NULL THEN excluded.parent_file_id ELSE files.parent_file_id END
|
||||
RETURNING *`
|
||||
);
|
||||
|
||||
@@ -132,9 +137,10 @@ export class DatabaseService {
|
||||
durationMs: record.durationMs,
|
||||
sampleRate: record.sampleRate,
|
||||
bitDepth: record.bitDepth,
|
||||
checksum: record.checksum,
|
||||
checksum: record.checksum,
|
||||
tagsJson: JSON.stringify(record.tags ?? []),
|
||||
categoriesJson: JSON.stringify(record.categories ?? [])
|
||||
categoriesJson: JSON.stringify(record.categories ?? []),
|
||||
parentFileId: record.parentFileId ?? null
|
||||
}) as DbRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
@@ -145,22 +151,35 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored tags and categories for a file.
|
||||
* Updates the stored tags and/or categories for a file.
|
||||
* When a field is omitted it remains unchanged.
|
||||
*/
|
||||
public updateTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
|
||||
public updateTagging(fileId: number, tags?: string[], categories?: string[]): AudioFileSummary {
|
||||
const connection = this.requireDb();
|
||||
const updates: string[] = [];
|
||||
const parameters: Record<string, unknown> = { id: fileId };
|
||||
|
||||
if (tags !== undefined) {
|
||||
updates.push('tags_json = @tags');
|
||||
parameters.tags = JSON.stringify(tags);
|
||||
}
|
||||
|
||||
if (categories !== undefined) {
|
||||
updates.push('categories_json = @categories');
|
||||
parameters.categories = JSON.stringify(categories);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return this.getFileById(fileId);
|
||||
}
|
||||
|
||||
const statement = connection.prepare(
|
||||
`UPDATE files
|
||||
SET tags_json = @tags,
|
||||
categories_json = @categories
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = @id
|
||||
RETURNING *`
|
||||
);
|
||||
const row = statement.get({
|
||||
id: fileId,
|
||||
tags: JSON.stringify(tags),
|
||||
categories: JSON.stringify(categories)
|
||||
}) as DbRow | undefined;
|
||||
const row = statement.get(parameters) as DbRow | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`File with id ${fileId} not found`);
|
||||
}
|
||||
@@ -408,6 +427,7 @@ export class DatabaseService {
|
||||
sampleRate: row.sample_rate === null ? null : (row.sample_rate as number),
|
||||
bitDepth: row.bit_depth === null ? null : (row.bit_depth as number),
|
||||
checksum: typeof row.checksum === 'string' ? (row.checksum as string) : null,
|
||||
parentFileId: typeof row.parent_file_id === 'number' ? (row.parent_file_id as number) : null,
|
||||
tags: this.parseJsonArray(row.tags_json),
|
||||
categories: this.parseJsonArray(row.categories_json),
|
||||
customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null
|
||||
@@ -444,7 +464,8 @@ export class DatabaseService {
|
||||
bit_depth INTEGER,
|
||||
checksum TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
categories_json TEXT NOT NULL DEFAULT '[]'
|
||||
categories_json TEXT NOT NULL DEFAULT '[]',
|
||||
parent_file_id INTEGER REFERENCES files(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_display_name ON files(display_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified_at);
|
||||
@@ -478,6 +499,7 @@ export class DatabaseService {
|
||||
this.addColumnIfMissing(connection, 'files', 'created_at', 'INTEGER');
|
||||
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
|
||||
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
|
||||
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
|
||||
|
||||
// Create checksum index after ensuring column exists
|
||||
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');
|
||||
|
||||
@@ -31,7 +31,6 @@ export class LibraryService {
|
||||
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,
|
||||
@@ -49,10 +48,10 @@ export class LibraryService {
|
||||
return;
|
||||
}
|
||||
const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8');
|
||||
const rows = parse(fileContent, {
|
||||
columns: true,
|
||||
skip_empty_lines: true
|
||||
}) as CsvCategoryRow[];
|
||||
const rows = parse(fileContent, {
|
||||
columns: true,
|
||||
skip_empty_lines: true
|
||||
}) as CsvCategoryRow[];
|
||||
for (const row of rows) {
|
||||
const category: CategoryRecord = {
|
||||
id: row.CatID,
|
||||
@@ -87,8 +86,9 @@ export class LibraryService {
|
||||
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 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, {
|
||||
@@ -117,9 +117,29 @@ export class LibraryService {
|
||||
const knownFile = knownByPath ?? knownByChecksum ?? null;
|
||||
const wasKnown = knownFile !== null;
|
||||
|
||||
// Read embedded WAV metadata (author, copyright, rating, title)
|
||||
// Read embedded WAV metadata (author, copyright, rating, title, parentId)
|
||||
const embeddedMetadata = this.tagService.readMetadata(absolutePath);
|
||||
|
||||
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,
|
||||
@@ -133,14 +153,36 @@ export class LibraryService {
|
||||
bitDepth: metadata.bitDepth,
|
||||
checksum,
|
||||
tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []),
|
||||
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? [])
|
||||
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []),
|
||||
parentFileId
|
||||
};
|
||||
const upserted = this.database.upsertFile(record);
|
||||
|
||||
// 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);
|
||||
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) {
|
||||
@@ -510,7 +552,9 @@ export class LibraryService {
|
||||
categories: updatedRecord.categories,
|
||||
title: effectiveCustomName,
|
||||
author: mergedAuthor,
|
||||
rating: mergedRating
|
||||
rating: mergedRating,
|
||||
copyright: existing.copyright ?? null,
|
||||
parentId: updatedRecord.parentFileId ?? null
|
||||
});
|
||||
|
||||
// Update suggestions cache for any metadata that was provided
|
||||
@@ -604,7 +648,9 @@ export class LibraryService {
|
||||
categories: updated.categories,
|
||||
title: effectiveCustomName,
|
||||
author: mergedAuthor,
|
||||
rating: mergedRating
|
||||
rating: mergedRating,
|
||||
copyright: existing.copyright ?? null,
|
||||
parentId: updated.parentFileId ?? null
|
||||
});
|
||||
|
||||
// Update suggestions cache for any metadata that was provided
|
||||
@@ -683,13 +729,14 @@ export class LibraryService {
|
||||
})();
|
||||
const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';
|
||||
|
||||
let originalMetadata: { author?: string | null; rating?: number; title?: string | null } = {};
|
||||
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
|
||||
title: metadata.title ?? null,
|
||||
copyright: metadata.copyright ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to read original metadata before splitting', error);
|
||||
@@ -739,12 +786,33 @@ export class LibraryService {
|
||||
(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) {
|
||||
const suffix = this.organization.formatSequenceNumber(sequence);
|
||||
sequence += 1;
|
||||
const candidateName = `${baseName}_segment${suffix}.wav`;
|
||||
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;
|
||||
}
|
||||
@@ -789,12 +857,15 @@ export class LibraryService {
|
||||
bitDepth: bitDepthNumeric,
|
||||
checksum,
|
||||
tags: resolvedTags,
|
||||
categories: resolvedCategories
|
||||
categories: resolvedCategories,
|
||||
parentFileId: record.id
|
||||
});
|
||||
|
||||
const resolvedCustomName = segment.metadata?.customName !== undefined
|
||||
? this.normaliseMetadataInput(segment.metadata.customName)
|
||||
: record.customName ?? null;
|
||||
: segment.label !== undefined
|
||||
? this.normaliseMetadataInput(segment.label)
|
||||
: record.customName ?? null;
|
||||
const updatedRecord = resolvedCustomName !== fileRecord.customName
|
||||
? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
|
||||
: fileRecord;
|
||||
@@ -811,7 +882,9 @@ export class LibraryService {
|
||||
categories: resolvedCategories,
|
||||
title: resolvedCustomName ?? updatedRecord.displayName,
|
||||
author: resolvedAuthor ?? undefined,
|
||||
rating: resolvedRating
|
||||
rating: resolvedRating,
|
||||
copyright: originalMetadata.copyright ?? null,
|
||||
parentId: record.id
|
||||
});
|
||||
|
||||
if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
|
||||
@@ -872,7 +945,9 @@ export class LibraryService {
|
||||
categories: record.categories,
|
||||
title: record.customName ?? existing.title,
|
||||
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
|
||||
rating: metadata.rating !== undefined ? metadata.rating : existing.rating
|
||||
rating: metadata.rating !== undefined ? metadata.rating : existing.rating,
|
||||
copyright: existing.copyright ?? null,
|
||||
parentId: record.parentFileId ?? null
|
||||
};
|
||||
|
||||
// Write merged metadata to the WAV file
|
||||
|
||||
@@ -10,23 +10,25 @@ export class TagService {
|
||||
public constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Applies tags and categories to a file record and embeds the metadata into the WAV container.
|
||||
* Applies category updates (and optional tag overrides) then embeds metadata into the WAV container.
|
||||
* Preserves existing author, title, and rating fields.
|
||||
*/
|
||||
public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
|
||||
const normalisedTags = this.normaliseValues(tags);
|
||||
public applyTagging(fileId: number, tags: string[] | undefined, categories: string[]): AudioFileSummary {
|
||||
const normalisedCategories = this.normaliseValues(categories);
|
||||
const normalisedTags = Array.isArray(tags) ? this.normaliseValues(tags) : undefined;
|
||||
const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories);
|
||||
|
||||
// Read existing metadata to preserve author, title, and rating
|
||||
const existing = this.readMetadata(updated.absolutePath);
|
||||
|
||||
this.writeWaveMetadata(updated.absolutePath, {
|
||||
tags: normalisedTags,
|
||||
categories: normalisedCategories,
|
||||
tags: normalisedTags ?? updated.tags,
|
||||
categories: normalisedCategories,
|
||||
title: existing.title,
|
||||
author: existing.author,
|
||||
rating: existing.rating
|
||||
rating: existing.rating,
|
||||
copyright: existing.copyright,
|
||||
parentId: existing.parentId ?? updated.parentFileId ?? null
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
@@ -52,6 +54,8 @@ export class TagService {
|
||||
author?: string;
|
||||
title?: string;
|
||||
rating?: number;
|
||||
copyright?: string;
|
||||
parentId?: number;
|
||||
} {
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
@@ -63,10 +67,44 @@ export class TagService {
|
||||
rating = Math.floor(ratingValue / 2);
|
||||
}
|
||||
|
||||
const copyright = tags.ICOP?.trim() || undefined;
|
||||
|
||||
// Try to read parentId from JSON comment first
|
||||
let parentId: number | undefined;
|
||||
const comment = tags.ICMT?.trim();
|
||||
if (comment) {
|
||||
try {
|
||||
const parsed = JSON.parse(comment);
|
||||
if (parsed && typeof parsed.parentId === 'number') {
|
||||
parentId = parsed.parentId;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON or invalid, try fallback to IPAR
|
||||
const parentRaw = tags.IPAR?.trim();
|
||||
if (parentRaw && parentRaw.length > 0) {
|
||||
const parsedNum = Number.parseInt(parentRaw, 10);
|
||||
if (Number.isFinite(parsedNum)) {
|
||||
parentId = parsedNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No comment, try fallback to IPAR
|
||||
const parentRaw = tags.IPAR?.trim();
|
||||
if (parentRaw && parentRaw.length > 0) {
|
||||
const parsedNum = Number.parseInt(parentRaw, 10);
|
||||
if (Number.isFinite(parsedNum)) {
|
||||
parentId = parsedNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
author: tags.IART?.trim() || undefined,
|
||||
title: tags.INAM?.trim() || undefined,
|
||||
rating
|
||||
rating,
|
||||
copyright,
|
||||
parentId
|
||||
};
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
||||
@@ -84,9 +122,11 @@ export class TagService {
|
||||
metadata: {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
title: string | null | undefined;
|
||||
author: string | null | undefined;
|
||||
rating: number | undefined;
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
rating?: number;
|
||||
copyright?: string | null;
|
||||
parentId?: number | null;
|
||||
}
|
||||
): void {
|
||||
this.writeWaveMetadata(filePath, metadata);
|
||||
@@ -101,9 +141,11 @@ export class TagService {
|
||||
metadata: {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
title: string | null | undefined;
|
||||
author: string | null | undefined;
|
||||
rating: number | undefined;
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
rating?: number;
|
||||
copyright?: string | null;
|
||||
parentId?: number | null;
|
||||
}
|
||||
): void {
|
||||
try {
|
||||
@@ -114,22 +156,47 @@ export class TagService {
|
||||
listInfoTags?: Record<string, string>;
|
||||
};
|
||||
|
||||
const tagValuesList = metadata.tags.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
const categoryValuesList = metadata.categories.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
const tagValuesList = (metadata.tags ?? [])
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
const categoryValuesList = (metadata.categories ?? [])
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null;
|
||||
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null;
|
||||
const primaryCategory = categoryValuesList.at(0) ?? null;
|
||||
const trimmedTitle = metadata.title?.toString().trim();
|
||||
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
|
||||
const trimmedAuthor = metadata.author?.toString().trim();
|
||||
const effectiveAuthor = trimmedAuthor && trimmedAuthor.length > 0 ? trimmedAuthor : null;
|
||||
const trimmedCopyright = metadata.copyright?.toString().trim();
|
||||
const effectiveCopyright = trimmedCopyright && trimmedCopyright.length > 0 ? trimmedCopyright : null;
|
||||
const parentText = metadata.parentId !== undefined && metadata.parentId !== null
|
||||
? String(metadata.parentId)
|
||||
: null;
|
||||
const commentPayload = {
|
||||
version: 1,
|
||||
tags: tagValuesList,
|
||||
categories: categoryValuesList,
|
||||
parentId: metadata.parentId ?? null,
|
||||
title: effectiveTitle,
|
||||
author: effectiveAuthor,
|
||||
rating: metadata.rating ?? null
|
||||
} satisfies Record<string, unknown>;
|
||||
const commentText = JSON.stringify(commentPayload);
|
||||
|
||||
const tagValues: Record<string, string | null> = {
|
||||
IKEY: tagText,
|
||||
ICMT: tagText,
|
||||
ICMT: commentText,
|
||||
ISBJ: categoryText,
|
||||
ISUB: primaryCategory,
|
||||
IGNR: null,
|
||||
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
|
||||
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
|
||||
INAM: effectiveTitle,
|
||||
IART: effectiveAuthor,
|
||||
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
|
||||
ICOP: effectiveCopyright,
|
||||
IPAR: parentText,
|
||||
ISFT: 'AudioSort'
|
||||
};
|
||||
|
||||
@@ -139,11 +206,13 @@ export class TagService {
|
||||
fs.writeFileSync(filePath, updatedBuffer);
|
||||
|
||||
console.log(`Wrote metadata to ${filePath}:`, {
|
||||
tags: metadata.tags.join(', '),
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '',
|
||||
categories: metadata.categories.join(', '),
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
rating: metadata.rating
|
||||
title: effectiveTitle,
|
||||
author: effectiveAuthor,
|
||||
copyright: effectiveCopyright,
|
||||
rating: metadata.rating,
|
||||
parentId: metadata.parentId ?? null
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
||||
|
||||
Reference in New Issue
Block a user