Files
embycovers/services/db.py
T
2026-06-08 00:01:55 +12:00

158 lines
5.0 KiB
Python

"""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 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)