feat: M1 foundation packages + trip-cluster app #1

Merged
m038 merged 19 commits from feat/m1-foundation-trip-cluster into master 2026-06-27 19:10:50 +02:00
4 changed files with 201 additions and 0 deletions
Showing only changes of commit 4db684fbdc - Show all commits
+3
View File
@@ -0,0 +1,3 @@
from photoflow.core.store import Store, SCHEMA_VERSION
__all__ = ["Store", "SCHEMA_VERSION"]
+66
View File
@@ -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
+108
View File
@@ -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
+24
View File
@@ -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()