mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 02:36:01 +10:00
508 lines
16 KiB
TypeScript
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 [];
|
|
}
|
|
}
|
|
}
|