This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/lib/db.ts
2026-04-19 21:41:34 -04:00

450 lines
15 KiB
TypeScript

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<string, string> = {
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<string, string[]> = {}
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)
);
`)
}