158 lines
5.0 KiB
Python
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)
|