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) migrateLibraryAiSettings(db) migrateAiJobs(db) migrateLibraryPermissionsAccessLevel(db) migrateLibrariesAddComics(db) migrateComicItemTypes(db) migrateImportedTags(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', ai_max_retries: '3', ai_max_tokens_tag: '8192', ai_max_tokens_describe: '8192', ai_max_tokens_extract: '8192', ai_max_tokens_translate: '8192', } 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 migrateLibraryAiSettings(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS library_ai_settings ( library_id TEXT PRIMARY KEY REFERENCES libraries(id) ON DELETE CASCADE, model_tagging TEXT, model_describe TEXT, model_extract TEXT, model_translate TEXT, prompt_describe TEXT, prompt_tagger TEXT, prompt_extract TEXT, prompt_translate TEXT ); `) // Add max_tokens columns if they don't exist yet const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_ai_settings'") .get() as { sql: string } | undefined if (row && !row.sql.includes('max_tokens_tag')) { db.exec(` ALTER TABLE library_ai_settings ADD COLUMN max_tokens_tag INTEGER; ALTER TABLE library_ai_settings ADD COLUMN max_tokens_describe INTEGER; ALTER TABLE library_ai_settings ADD COLUMN max_tokens_extract INTEGER; ALTER TABLE library_ai_settings ADD COLUMN max_tokens_translate INTEGER; `) } } 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; `) } } function migrateLibrariesAddComics(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("'comics'")) return 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 ('comics','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; `) } function migrateComicItemTypes(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("'comic_series'")) return 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', 'comic_series','comic_issue')), parent_key TEXT, title TEXT, year INTEGER, plot TEXT, genres TEXT, metadata TEXT, file_path TEXT, fingerprint TEXT, scanned_at INTEGER NOT NULL, ai_tagged_at INTEGER, ai_description TEXT, extracted_text TEXT, extracted_text_translated TEXT ); INSERT INTO media_items_new SELECT * 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); CREATE INDEX media_items_fingerprint ON media_items(fingerprint); COMMIT; `) } function migrateLibraryPermissionsAccessLevel(db: Database.Database): void { const row = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'") .get() as { sql: string } | undefined if (row && !row.sql.includes('access_level')) { db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`) } } function migrateAiJobs(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS ai_jobs ( id TEXT PRIMARY KEY, item_key TEXT NOT NULL, library_id TEXT NOT NULL, job_type TEXT NOT NULL CHECK(job_type IN ('tag','describe','extract','translate')), status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','completed','failed')), error TEXT, attempt INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 3, created_at INTEGER NOT NULL, started_at INTEGER, completed_at INTEGER, item_title TEXT ); CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status); CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at); `) // Add payload column if not present const aiJobsRow = db .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'") .get() as { sql: string } | undefined if (aiJobsRow && !aiJobsRow.sql.includes('payload')) { db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT') } } function migrateImportedTags(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS imported_tags ( id TEXT PRIMARY KEY, library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, name TEXT NOT NULL, UNIQUE(library_id, name) ); CREATE TABLE IF NOT EXISTS item_imported_tags ( item_key TEXT NOT NULL, imported_tag_id TEXT NOT NULL REFERENCES imported_tags(id) ON DELETE CASCADE, PRIMARY KEY (item_key, imported_tag_id) ); CREATE TABLE IF NOT EXISTS tag_mappings ( id TEXT PRIMARY KEY, library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, imported_tag_name TEXT NOT NULL, tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, UNIQUE(library_id, imported_tag_name) ); `) }