Homelabtoolkit v1
This commit is contained in:
+157
@@ -0,0 +1,157 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user