Files
AudioSort/src/main/services/DatabaseService.ts
2025-11-06 02:27:46 +11:00

508 lines
16 KiB
TypeScript

/**
* Thin wrapper around better-sqlite3 providing schema setup and helpers used by the main process services.
*/
import Database, { Database as BetterSqliteDatabase } from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { AppSettings, AudioFileSummary, CategoryRecord } from '../../shared/models';
export interface FileRecordInput {
/** Absolute filesystem path. */
absolutePath: string;
/** Relative path inside the library root. */
relativePath: string;
/** Filename including extension. */
fileName: string;
/** Display name without extension. */
displayName: string;
/** Unix epoch milliseconds. */
modifiedAt: number;
/** File creation time if available. */
createdAt: number | null;
/** File size in bytes. */
size: number;
/** Duration in milliseconds if known. */
durationMs: number | null;
/** Sample rate in Hz if known. */
sampleRate: number | null;
/** Bit depth if known. */
bitDepth: number | null;
/** MD5 checksum of the file contents. */
checksum: string | null;
/** Optional tag payload (stored as JSON string). */
tags?: string[];
/** Optional category payload (stored as JSON string). */
categories?: string[];
}
export interface FileRecordRow extends AudioFileSummary {}
type DbRow = Record<string, unknown>;
/**
* Handles persistence for files, categories, and settings.
*/
export class DatabaseService {
private db: BetterSqliteDatabase | null = null;
public constructor(private readonly dbFilePath: string) {}
/**
* Opens the database connection (creating the file if necessary) and ensures the schema exists.
*/
public initialize(): void {
const folder = path.dirname(this.dbFilePath);
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder, { recursive: true });
}
this.db = new Database(this.dbFilePath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
this.applySchema();
}
/**
* Closes the active database connection.
*/
public close(): void {
this.db?.close();
this.db = null;
}
/**
* Persists or updates a file row and returns the stored record.
* @param record File description to persist.
*/
public upsertFile(record: FileRecordInput): FileRecordRow {
const connection = this.requireDb();
const statement = connection.prepare(
`INSERT INTO files (
absolute_path,
library_relative_path,
file_name,
display_name,
modified_at,
created_at,
size_bytes,
duration_ms,
sample_rate,
bit_depth,
checksum,
tags_json,
categories_json
) VALUES (
@absolutePath,
@relativePath,
@fileName,
@displayName,
@modifiedAt,
@createdAt,
@size,
@durationMs,
@sampleRate,
@bitDepth,
@checksum,
@tagsJson,
@categoriesJson
)
ON CONFLICT(absolute_path) DO UPDATE SET
library_relative_path = excluded.library_relative_path,
file_name = excluded.file_name,
display_name = excluded.display_name,
modified_at = excluded.modified_at,
created_at = COALESCE(files.created_at, excluded.created_at),
size_bytes = excluded.size_bytes,
duration_ms = excluded.duration_ms,
sample_rate = excluded.sample_rate,
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
RETURNING *`
);
const row = statement.get({
absolutePath: record.absolutePath,
relativePath: record.relativePath,
fileName: record.fileName,
displayName: record.displayName,
modifiedAt: record.modifiedAt,
createdAt: record.createdAt,
size: record.size,
durationMs: record.durationMs,
sampleRate: record.sampleRate,
bitDepth: record.bitDepth,
checksum: record.checksum,
tagsJson: JSON.stringify(record.tags ?? []),
categoriesJson: JSON.stringify(record.categories ?? [])
}) as DbRow | undefined;
if (!row) {
throw new Error('Failed to persist file record.');
}
return this.mapFileRow(row);
}
/**
* Updates the stored tags and categories for a file.
*/
public updateTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
const connection = this.requireDb();
const statement = connection.prepare(
`UPDATE files
SET tags_json = @tags,
categories_json = @categories
WHERE id = @id
RETURNING *`
);
const row = statement.get({
id: fileId,
tags: JSON.stringify(tags),
categories: JSON.stringify(categories)
}) as DbRow | undefined;
if (!row) {
throw new Error(`File with id ${fileId} not found`);
}
return this.mapFileRow(row);
}
/**
* Updates the custom name for a file.
*/
public updateCustomName(fileId: number, customName: string | null): AudioFileSummary {
const connection = this.requireDb();
const statement = connection.prepare(
`UPDATE files
SET custom_name = @customName
WHERE id = @id
RETURNING *`
);
const row = statement.get({
id: fileId,
customName
}) as DbRow | undefined;
if (!row) {
throw new Error(`File with id ${fileId} not found`);
}
return this.mapFileRow(row);
}
/**
* Returns a single file row by id.
*/
public getFileById(fileId: number): AudioFileSummary {
const row = this.requireDb()
.prepare('SELECT * FROM files WHERE id = ?')
.get(fileId) as DbRow | undefined;
if (!row) {
throw new Error(`File with id ${fileId} not found`);
}
return this.mapFileRow(row);
}
/**
* Lists all audio files currently known to the database.
*/
public listFiles(): AudioFileSummary[] {
const rows = this.requireDb()
.prepare('SELECT * FROM files ORDER BY display_name COLLATE NOCASE ASC')
.all() as DbRow[];
return rows.map((row) => this.mapFileRow(row));
}
/**
* Deletes a file record permanently.
*/
public deleteFile(fileId: number): void {
const statement = this.requireDb().prepare('DELETE FROM files WHERE id = ?');
statement.run(fileId);
}
/**
* Returns groups of files that share the same checksum.
*/
public listDuplicateGroups(): { checksum: string; files: AudioFileSummary[] }[] {
const connection = this.requireDb();
const checksumRows = connection
.prepare(`
SELECT checksum
FROM files
WHERE checksum IS NOT NULL AND checksum <> ''
GROUP BY checksum
HAVING COUNT(*) > 1
`)
.all() as Array<{ checksum: string }>;
const fileQuery = connection.prepare('SELECT * FROM files WHERE checksum = ? ORDER BY created_at ASC, modified_at ASC');
return checksumRows.map((row) => ({
checksum: row.checksum,
files: (fileQuery.all(row.checksum) as DbRow[]).map((fileRow) => this.mapFileRow(fileRow))
}));
}
/**
* Deletes file records whose absolute path is not part of the provided set.
* @param validPathsSet Set of absolute paths that should remain in the database.
* @returns Number of removed rows.
*/
public removeFilesOutside(validPathsSet: Set<string>): number {
const connection = this.requireDb();
const rows = connection.prepare('SELECT id, absolute_path FROM files').all() as DbRow[];
let removed = 0;
const deleteStatement = connection.prepare('DELETE FROM files WHERE id = ?');
for (const row of rows) {
const absolutePath = row.absolute_path as string | undefined;
const id = row.id as number | undefined;
if (!absolutePath || typeof id !== 'number') {
continue;
}
if (!validPathsSet.has(absolutePath)) {
deleteStatement.run(id);
removed += 1;
}
}
return removed;
}
/**
* Updates file path and naming metadata after a rename or move operation.
*/
public updateFileLocation(
fileId: number,
absolutePath: string,
relativePath: string,
fileName: string,
displayName: string
): AudioFileSummary {
const row = this.requireDb()
.prepare(
`UPDATE files
SET absolute_path = @absolutePath,
library_relative_path = @relativePath,
file_name = @fileName,
display_name = @displayName
WHERE id = @id
RETURNING *`
)
.get({ id: fileId, absolutePath, relativePath, fileName, displayName }) as DbRow | undefined;
if (!row) {
throw new Error(`File with id ${fileId} not found`);
}
return this.mapFileRow(row);
}
/**
* Retrieves application settings as a typed object.
*/
public getSettings(): AppSettings {
const connection = this.requireDb();
const rows = connection.prepare('SELECT key, value FROM settings').all() as DbRow[];
const map = new Map<string, string>();
for (const row of rows) {
const key = row.key as string | undefined;
const value = row.value as string | undefined;
if (key && typeof value === 'string') {
map.set(key, value);
}
}
return {
libraryPath: map.has('libraryPath') ? (JSON.parse(map.get('libraryPath') as string) as string) : null
};
}
/**
* Persists a single setting key.
*/
public setSetting(key: string, value: unknown): void {
this.requireDb()
.prepare(
`INSERT INTO settings (key, value) VALUES (@key, @value)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
)
.run({ key, value: JSON.stringify(value) });
}
/**
* Inserts or updates a UCS category record.
*/
public upsertCategory(category: CategoryRecord): void {
this.requireDb()
.prepare(
`INSERT INTO categories (
id,
category,
sub_category,
short_code,
explanation,
synonyms_json
) VALUES (@id, @category, @subCategory, @shortCode, @explanation, @synonyms)
ON CONFLICT(id) DO UPDATE SET
category = excluded.category,
sub_category = excluded.sub_category,
short_code = excluded.short_code,
explanation = excluded.explanation,
synonyms_json = excluded.synonyms_json`
)
.run({
id: category.id,
category: category.category,
subCategory: category.subCategory,
shortCode: category.shortCode,
explanation: category.explanation,
synonyms: JSON.stringify(category.synonyms)
});
}
/**
* Returns the full catalog of UCS categories.
*/
public listCategories(): CategoryRecord[] {
const rows = this.requireDb()
.prepare('SELECT * FROM categories ORDER BY category, sub_category')
.all() as DbRow[];
return rows.map((row) => ({
id: row.id as string,
category: row.category as string,
subCategory: row.sub_category as string,
shortCode: row.short_code as string,
explanation: row.explanation as string,
synonyms: this.parseJsonArray(row.synonyms_json)
}));
}
/**
* Retrieves a category by its CatID.
*/
public getCategoryById(catId: string): CategoryRecord | null {
const row = this.requireDb()
.prepare('SELECT * FROM categories WHERE id = ?')
.get(catId) as DbRow | undefined;
if (!row) {
return null;
}
return {
id: row.id as string,
category: row.category as string,
subCategory: row.sub_category as string,
shortCode: row.short_code as string,
explanation: row.explanation as string,
synonyms: this.parseJsonArray(row.synonyms_json)
};
}
/**
* Maps a raw database row to the strongly typed summary shape.
*/
private mapFileRow(row: DbRow): AudioFileSummary {
return {
id: row.id as number,
absolutePath: row.absolute_path as string,
relativePath: row.library_relative_path as string,
fileName: row.file_name as string,
displayName: row.display_name as string,
modifiedAt: row.modified_at as number,
createdAt: row.created_at === null || row.created_at === undefined ? null : (row.created_at as number),
size: row.size_bytes as number,
durationMs: row.duration_ms === null ? null : (row.duration_ms as number),
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,
tags: this.parseJsonArray(row.tags_json),
categories: this.parseJsonArray(row.categories_json),
customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null
};
}
/**
* Lazy accessor ensuring the database has been initialised.
*/
private requireDb(): BetterSqliteDatabase {
if (!this.db) {
throw new Error('Database connection has not been initialised.');
}
return this.db;
}
/**
* Applies the initial schema for the application.
*/
private applySchema(): void {
const connection = this.requireDb();
connection.exec(`
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absolute_path TEXT NOT NULL UNIQUE,
library_relative_path TEXT NOT NULL,
file_name TEXT NOT NULL,
display_name TEXT NOT NULL,
modified_at INTEGER NOT NULL,
created_at INTEGER,
size_bytes INTEGER NOT NULL,
duration_ms INTEGER,
sample_rate INTEGER,
bit_depth INTEGER,
checksum TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
categories_json TEXT NOT NULL DEFAULT '[]'
);
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);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
sub_category TEXT NOT NULL,
short_code TEXT NOT NULL,
explanation TEXT NOT NULL,
synonyms_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS file_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL,
previous_path TEXT NOT NULL,
new_path TEXT NOT NULL,
operation_type TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE
);
`);
// Add newly introduced columns if the table already existed.
this.addColumnIfMissing(connection, 'files', 'created_at', 'INTEGER');
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
// Create checksum index after ensuring column exists
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');
}
private addColumnIfMissing(connection: BetterSqliteDatabase, table: string, column: string, definition: string): void {
const info = connection.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (!info.some((row) => row.name === column)) {
connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
/**
* Safely parses stored JSON arrays, handling legacy nulls or malformed values.
*/
private parseJsonArray(value: unknown): string[] {
if (typeof value !== 'string' || value.length === 0) {
return [];
}
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
} catch {
return [];
}
}
}