From 4db684fbdca0a44ec0b41d72699bf1df98c8b992 Mon Sep 17 00:00:00 2001 From: mischa Date: Sat, 27 Jun 2026 17:11:49 +0200 Subject: [PATCH] feat(core): domain models + Store (schema, connection, meta) --- shared/photoflow/core/__init__.py | 3 + shared/photoflow/core/models.py | 66 ++++++++++++++++++ shared/photoflow/core/store.py | 108 ++++++++++++++++++++++++++++++ shared/tests/test_store_assets.py | 24 +++++++ 4 files changed, 201 insertions(+) create mode 100644 shared/photoflow/core/models.py create mode 100644 shared/photoflow/core/store.py create mode 100644 shared/tests/test_store_assets.py diff --git a/shared/photoflow/core/__init__.py b/shared/photoflow/core/__init__.py index e69de29..187dad4 100644 --- a/shared/photoflow/core/__init__.py +++ b/shared/photoflow/core/__init__.py @@ -0,0 +1,3 @@ +from photoflow.core.store import Store, SCHEMA_VERSION + +__all__ = ["Store", "SCHEMA_VERSION"] diff --git a/shared/photoflow/core/models.py b/shared/photoflow/core/models.py new file mode 100644 index 0000000..8e9fd1e --- /dev/null +++ b/shared/photoflow/core/models.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Asset: + immich_id: str + taken_at: str = "" + gps_lat: Optional[float] = None + gps_lon: Optional[float] = None + place_city: Optional[str] = None + place_country: Optional[str] = None + type: str = "" + has_gps: bool = False + thumb_path: Optional[str] = None + processed: bool = False + ingested_at: str = "" + updated_at: str = "" + + +@dataclass +class Tag: + name: str + immich_tag_id: Optional[str] = None + count: int = 0 + + +@dataclass +class AssetTag: + immich_id: str + tag_name: str + + +@dataclass +class Cluster: + id: Optional[int] = None + start_at: str = "" + end_at: str = "" + count: int = 0 + suggested_name: str = "" + confidence: float = 0.0 + kind_guess: str = "trip" # trip | everyday + status: str = "pending" # pending|approved|non_trip|merged|split|skipped + decided_name: Optional[str] = None + reviewed_at: Optional[str] = None + notes: Optional[str] = None + + +@dataclass +class ClusterMember: + cluster_id: int + immich_id: str + member_confidence: float = 1.0 + is_outlier: bool = False + included: bool = True + flagged_coverage: bool = False + + +@dataclass +class WritebackLog: + id: Optional[int] + immich_id: str + action: str # trip | non-trip | processed + tag: Optional[str] + result: str # ok | error: + applied_at: str diff --git a/shared/photoflow/core/store.py b/shared/photoflow/core/store.py new file mode 100644 index 0000000..a7877d3 --- /dev/null +++ b/shared/photoflow/core/store.py @@ -0,0 +1,108 @@ +import sqlite3 + +SCHEMA_VERSION = 1 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT); + +CREATE TABLE IF NOT EXISTS assets ( + immich_id TEXT PRIMARY KEY, + taken_at TEXT, + gps_lat REAL, gps_lon REAL, + place_city TEXT, place_country TEXT, + type TEXT, + has_gps INTEGER NOT NULL DEFAULT 0, + thumb_path TEXT, + processed INTEGER NOT NULL DEFAULT 0, + ingested_at TEXT, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS tags ( + name TEXT PRIMARY KEY, + immich_tag_id TEXT, + count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS asset_tags ( + immich_id TEXT NOT NULL, + tag_name TEXT NOT NULL, + PRIMARY KEY (immich_id, tag_name) +); + +CREATE TABLE IF NOT EXISTS clusters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_at TEXT, end_at TEXT, + count INTEGER NOT NULL DEFAULT 0, + suggested_name TEXT, + confidence REAL NOT NULL DEFAULT 0, + kind_guess TEXT NOT NULL DEFAULT 'trip', + status TEXT NOT NULL DEFAULT 'pending', + decided_name TEXT, + reviewed_at TEXT, + notes TEXT +); + +CREATE TABLE IF NOT EXISTS cluster_members ( + cluster_id INTEGER NOT NULL, + immich_id TEXT NOT NULL, + member_confidence REAL NOT NULL DEFAULT 1.0, + is_outlier INTEGER NOT NULL DEFAULT 0, + included INTEGER NOT NULL DEFAULT 1, + flagged_coverage INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (cluster_id, immich_id) +); + +CREATE TABLE IF NOT EXISTS writeback_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + immich_id TEXT NOT NULL, + action TEXT NOT NULL, + tag TEXT, + result TEXT NOT NULL, + applied_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_assets_taken_at ON assets(taken_at); +CREATE INDEX IF NOT EXISTS idx_members_cluster ON cluster_members(cluster_id); +CREATE INDEX IF NOT EXISTS idx_members_asset ON cluster_members(immich_id); +""" + + +class Store: + def __init__(self, db_path: str): + self.db_path = db_path + self._conn = None + + def connect(self) -> "Store": + self._conn = sqlite3.connect(self.db_path) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA foreign_keys=ON") + self.migrate() + return self + + @property + def conn(self) -> sqlite3.Connection: + if self._conn is None: + raise RuntimeError("Store not connected; call connect()") + return self._conn + + def migrate(self) -> None: + self.conn.executescript(SCHEMA) + if self.get_meta("schema_version") is None: + self.set_meta("schema_version", str(SCHEMA_VERSION)) + self.conn.commit() + + def set_meta(self, key: str, value: str) -> None: + self.conn.execute( + "INSERT INTO meta(key, value) VALUES(?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value)) + self.conn.commit() + + def get_meta(self, key: str, default=None): + row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() + return row["value"] if row else default + + def close(self) -> None: + if self._conn is not None: + self._conn.close() + self._conn = None diff --git a/shared/tests/test_store_assets.py b/shared/tests/test_store_assets.py new file mode 100644 index 0000000..f60a65b --- /dev/null +++ b/shared/tests/test_store_assets.py @@ -0,0 +1,24 @@ +from photoflow.core import Store + + +def test_connect_creates_schema_and_version(tmp_path): + db = str(tmp_path / "t.db") + s = Store(db).connect() + assert s.get_meta("schema_version") == "1" + # tables exist + names = {r["name"] for r in s.conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'")} + assert {"assets", "tags", "asset_tags", "clusters", + "cluster_members", "writeback_log", "meta"} <= names + s.close() + + +def test_meta_roundtrip_and_default(tmp_path): + s = Store(str(tmp_path / "t.db")).connect() + assert s.get_meta("missing") is None + assert s.get_meta("missing", "x") == "x" + s.set_meta("last_ingest_at", "2026-06-27T00:00:00Z") + assert s.get_meta("last_ingest_at") == "2026-06-27T00:00:00Z" + s.set_meta("last_ingest_at", "newer") # upsert + assert s.get_meta("last_ingest_at") == "newer" + s.close()