import path from 'path' import fs from 'fs' import Database from 'better-sqlite3' const CONFIG_PATH = process.env.CONFIG_PATH ?? process.cwd() const DB_PATH = path.resolve(CONFIG_PATH, 'medialore.db') let _db: Database.Database | null = null export function getDb(): Database.Database { if (_db) return _db _db = new Database(DB_PATH) _db.pragma('journal_mode = WAL') _db.pragma('foreign_keys = ON') initDb(_db) return _db } function initDb(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS tag_categories ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS tags ( id TEXT PRIMARY KEY, name TEXT NOT NULL, category_id TEXT NOT NULL REFERENCES tag_categories(id) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id); CREATE TABLE IF NOT EXISTS media_tags ( item_key TEXT NOT NULL, tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (item_key, tag_id) ); CREATE TABLE IF NOT EXISTS libraries ( id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')), cover_ext TEXT NULL ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('admin', 'user')), created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS library_permissions ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, PRIMARY KEY (user_id, library_id) ); CREATE TABLE IF NOT EXISTS user_settings ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, mixed_autoplay INTEGER NOT NULL DEFAULT 1, mixed_loop INTEGER NOT NULL DEFAULT 1, mixed_muted INTEGER NOT NULL DEFAULT 1, movies_autoplay INTEGER NOT NULL DEFAULT 1, movies_loop INTEGER NOT NULL DEFAULT 0, movies_muted INTEGER NOT NULL DEFAULT 0, tv_autoplay INTEGER NOT NULL DEFAULT 1, tv_loop INTEGER NOT NULL DEFAULT 0, tv_muted INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS media_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, item_key TEXT NOT NULL UNIQUE, item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series','mixed_file')), parent_key TEXT, title TEXT, year INTEGER, plot TEXT, genres TEXT, metadata TEXT, file_path TEXT, fingerprint TEXT, scanned_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS media_items_library_id ON media_items(library_id); CREATE INDEX IF NOT EXISTS media_items_parent_key ON media_items(parent_key); CREATE INDEX IF NOT EXISTS media_items_fingerprint ON media_items(fingerprint); `) migrateLibrariesType(db) migrateMediaItemsSchema(db) migrateMediaItemsFingerprint(db) migrateMediaTagsToItemKey(db) migrateMediaItemsAiTagged(db) migrateMediaItemsAiFields(db) seedAppSettings(db) } function seedAppSettings(db: Database.Database): void { const defaults: Record = { scan_schedule: '0 * * * *', scan_enabled: 'true', scan_last_ran: '', ai_enabled: 'false', ai_endpoint: '', ai_model: '', preferred_language: 'English', } const insert = db.prepare( 'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)' ) for (const [key, value] of Object.entries(defaults)) { insert.run(key, value) } } function migrateMediaItemsSchema(db: Database.Database): void { const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'") .get() as { sql: string } | undefined if (!row) return const needsFilePath = !row.sql.includes('file_path') const needsMixedFile = !row.sql.includes("'mixed_file'") if (!needsFilePath && !needsMixedFile) return // Determine whether the current table already has file_path (partial migration) const hasFilePath = !needsFilePath ? 'file_path,' : 'NULL as file_path,' db.exec(` BEGIN TRANSACTION; CREATE TABLE media_items_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, item_key TEXT NOT NULL UNIQUE, item_type TEXT NOT NULL CHECK(item_type IN ( 'movie','tv_series','tv_season','tv_episode', 'game','game_series','mixed_file')), parent_key TEXT, title TEXT, year INTEGER, plot TEXT, genres TEXT, metadata TEXT, file_path TEXT, scanned_at INTEGER NOT NULL ); INSERT INTO media_items_new SELECT id, library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, ${hasFilePath} scanned_at FROM media_items; DROP TABLE media_items; ALTER TABLE media_items_new RENAME TO media_items; CREATE INDEX media_items_library_id ON media_items(library_id); CREATE INDEX media_items_parent_key ON media_items(parent_key); COMMIT; `) } function migrateMediaItemsFingerprint(db: Database.Database): void { const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'") .get() as { sql: string } | undefined if (row && !row.sql.includes('fingerprint')) { db.exec(` ALTER TABLE media_items ADD COLUMN fingerprint TEXT; CREATE INDEX IF NOT EXISTS media_items_fingerprint ON media_items(fingerprint); `) } } function migrateMediaTagsToItemKey(db: Database.Database): void { const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_tags'") .get() as { sql: string } | undefined if (!row || !row.sql.includes('media_key')) return // Already migrated or table doesn't exist // Create replacement table with item_key column db.exec(` CREATE TABLE media_tags_new ( item_key TEXT NOT NULL, tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (item_key, tag_id) ) `) // Build reverse mapping: short media_key → full item_key // Uses same logic as the old itemKeyToMediaKey: libraryId + lastSegment const items = db .prepare('SELECT item_key FROM media_items') .all() as { item_key: string }[] const shortToFull: Record = {} for (const { item_key } of items) { const firstColon = item_key.indexOf(':') const lastColon = item_key.lastIndexOf(':') const libraryId = item_key.slice(0, firstColon) const shortId = item_key.slice(lastColon + 1) const mediaKey = `${libraryId}:${shortId}` ;(shortToFull[mediaKey] ??= []).push(item_key) } const tagRows = db .prepare('SELECT media_key, tag_id FROM media_tags') .all() as { media_key: string; tag_id: string }[] const insert = db.prepare('INSERT OR IGNORE INTO media_tags_new (item_key, tag_id) VALUES (?, ?)') db.transaction(() => { for (const { media_key, tag_id } of tagRows) { const candidates = shortToFull[media_key] if (!candidates || candidates.length !== 1) continue // orphaned or ambiguous collision insert.run(candidates[0], tag_id) } })() db.exec(` DROP TABLE media_tags; ALTER TABLE media_tags_new RENAME TO media_tags; `) } function migrateMediaItemsAiTagged(db: Database.Database): void { const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'") .get() as { sql: string } | undefined if (row && !row.sql.includes('ai_tagged_at')) { db.exec('ALTER TABLE media_items ADD COLUMN ai_tagged_at INTEGER') } } function migrateMediaItemsAiFields(db: Database.Database): void { const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'") .get() as { sql: string } | undefined if (!row) return if (!row.sql.includes('ai_description')) { db.exec('ALTER TABLE media_items ADD COLUMN ai_description TEXT') } if (!row.sql.includes('extracted_text')) { db.exec('ALTER TABLE media_items ADD COLUMN extracted_text TEXT') } if (!row.sql.includes('extracted_text_translated')) { db.exec('ALTER TABLE media_items ADD COLUMN extracted_text_translated TEXT') } } function migrateLibrariesType(db: Database.Database): void { const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'") .get() as { sql: string } | undefined if (row && !row.sql.includes("'movies'")) { db.exec(` BEGIN TRANSACTION; CREATE TABLE libraries_new ( id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')), cover_ext TEXT NULL ); INSERT INTO libraries_new SELECT * FROM libraries; DROP TABLE libraries; ALTER TABLE libraries_new RENAME TO libraries; COMMIT; `) } }