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-12 18:18:59 -04:00

284 lines
9.4 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)
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',
}
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 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;
`)
}
}