"""SQLite database layer for HomelabToolkit. Boring and database-first: a single local SQLite file holds the music-library scan results, MusicBrainz cache, and collection-completeness data. The schema is created idempotently at startup (``init_db``), which doubles as the migration — every statement uses ``IF NOT EXISTS``. Connections are opened per call (cheap for SQLite) so each thread/request gets its own handle. WAL mode lets the UI keep reading while a scan job writes. """ from __future__ import annotations import os import sqlite3 from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path DB_PATH = Path(os.environ.get("DB_PATH", "cache/homelab.db")) def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") @contextmanager def connect(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(DB_PATH), timeout=30) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA busy_timeout=8000") try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() SCHEMA = """ CREATE TABLE IF NOT EXISTS library_scan_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, -- 'scan' | 'metadata' status TEXT NOT NULL, -- 'running' | 'completed' | 'failed' started_at TEXT, completed_at TEXT, error_message TEXT, files_scanned INTEGER DEFAULT 0, albums_found INTEGER DEFAULT 0, artists_found INTEGER DEFAULT 0, progress TEXT ); CREATE TABLE IF NOT EXISTS library_artists ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, name_normalized TEXT NOT NULL UNIQUE, mbid TEXT, is_various INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS library_albums ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE, title TEXT NOT NULL, title_normalized TEXT NOT NULL, year INTEGER DEFAULT 0, mbid TEXT, is_active INTEGER DEFAULT 1, created_at TEXT, updated_at TEXT, UNIQUE(artist_id, title_normalized, year) ); CREATE TABLE IF NOT EXISTS library_tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, album_id INTEGER NOT NULL REFERENCES library_albums(id) ON DELETE CASCADE, title TEXT, track_number INTEGER, disc_number INTEGER, file_path TEXT NOT NULL UNIQUE, file_mtime REAL, file_size INTEGER, mbid TEXT, is_active INTEGER DEFAULT 1, last_seen_scan_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS external_artist_matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL UNIQUE REFERENCES library_artists(id) ON DELETE CASCADE, mb_artist_mbid TEXT, mb_artist_name TEXT, confidence REAL DEFAULT 0, status TEXT, -- 'matched' | 'not_found' | 'error' | 'skipped' checked_at TEXT ); CREATE TABLE IF NOT EXISTS external_releases ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE, mb_release_group_mbid TEXT NOT NULL, title TEXT NOT NULL, title_normalized TEXT, first_release_year INTEGER DEFAULT 0, primary_type TEXT, secondary_types TEXT, -- JSON array fetched_at TEXT, UNIQUE(artist_id, mb_release_group_mbid) ); CREATE TABLE IF NOT EXISTS collection_completeness ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE, release_group_mbid TEXT NOT NULL, local_album_id INTEGER REFERENCES library_albums(id) ON DELETE SET NULL, title TEXT, year INTEGER DEFAULT 0, status TEXT NOT NULL, -- owned|probably_owned|missing|ignored|uncertain confidence REAL DEFAULT 0, reason TEXT, source TEXT DEFAULT 'musicbrainz', manual_override INTEGER DEFAULT 0, updated_at TEXT, UNIQUE(artist_id, release_group_mbid) ); CREATE TABLE IF NOT EXISTS mb_cache ( cache_key TEXT PRIMARY KEY, payload TEXT, fetched_at TEXT ); CREATE TABLE IF NOT EXISTS genre_overrides ( artist_key TEXT PRIMARY KEY, -- normalized (lowercased) artist name artist TEXT NOT NULL, -- original display casing genre TEXT NOT NULL, updated_at TEXT ); CREATE INDEX IF NOT EXISTS idx_albums_artist ON library_albums(artist_id); CREATE INDEX IF NOT EXISTS idx_tracks_album ON library_tracks(album_id); CREATE INDEX IF NOT EXISTS idx_tracks_path ON library_tracks(file_path); CREATE INDEX IF NOT EXISTS idx_releases_artist ON external_releases(artist_id); CREATE INDEX IF NOT EXISTS idx_completeness_artist ON collection_completeness(artist_id); CREATE INDEX IF NOT EXISTS idx_completeness_status ON collection_completeness(status); CREATE INDEX IF NOT EXISTS idx_scan_runs_kind ON library_scan_runs(kind, id); """ def init_db() -> None: with connect() as conn: conn.executescript(SCHEMA)