284 lines
9.4 KiB
TypeScript
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;
|
|
`)
|
|
}
|
|
}
|