feat: M1 foundation packages + trip-cluster app #1
@@ -0,0 +1,3 @@
|
|||||||
|
from photoflow.core.store import Store, SCHEMA_VERSION
|
||||||
|
|
||||||
|
__all__ = ["Store", "SCHEMA_VERSION"]
|
||||||
|
|||||||
@@ -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:<reason>
|
||||||
|
applied_at: str
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user