feat: M1 foundation packages + trip-cluster app #1
@@ -0,0 +1,21 @@
|
|||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from app.config import load_config
|
||||||
|
from photoflow.ui import register_shared_ui
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config=None) -> Flask:
|
||||||
|
app = Flask(__name__)
|
||||||
|
cfg = config or load_config()
|
||||||
|
app.config["APP_CONFIG"] = cfg
|
||||||
|
app.config["DATA_DIR"] = cfg.data_dir
|
||||||
|
|
||||||
|
register_shared_ui(app)
|
||||||
|
|
||||||
|
from app.routes.nav import bp as nav_bp
|
||||||
|
from app.routes.review import bp as review_bp
|
||||||
|
from app.routes.proxy import bp as proxy_bp
|
||||||
|
app.register_blueprint(nav_bp)
|
||||||
|
app.register_blueprint(review_bp)
|
||||||
|
app.register_blueprint(proxy_bp)
|
||||||
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
|
||||||
|
def _store(cfg):
|
||||||
|
import os
|
||||||
|
from photoflow.core import Store
|
||||||
|
os.makedirs(cfg.data_dir, exist_ok=True)
|
||||||
|
return Store(cfg.db_path).connect()
|
||||||
|
|
||||||
|
|
||||||
|
def _immich(deps, cfg):
|
||||||
|
if "immich" in deps:
|
||||||
|
return deps["immich"]
|
||||||
|
from photoflow.immich import ImmichClient
|
||||||
|
return ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_serve(deps) -> int:
|
||||||
|
from app import create_app
|
||||||
|
create_app(deps["config"]).run(host="0.0.0.0", port=8084)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="categorize")
|
||||||
|
sub = p.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
ing = sub.add_parser("ingest")
|
||||||
|
ing.add_argument("--from", dest="date_from")
|
||||||
|
ing.add_argument("--to", dest="date_to")
|
||||||
|
ing.add_argument("--tag")
|
||||||
|
ing.add_argument("--subset", type=int)
|
||||||
|
ing.add_argument("--full", action="store_true", help="ignore incremental updatedAfter")
|
||||||
|
|
||||||
|
cl = sub.add_parser("cluster")
|
||||||
|
cl.add_argument("--gap-factor", type=float, default=6.0)
|
||||||
|
|
||||||
|
sub.add_parser("serve")
|
||||||
|
|
||||||
|
ap = sub.add_parser("apply")
|
||||||
|
ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
deps = {"config": load_config()}
|
||||||
|
if args.command == "serve":
|
||||||
|
return cmd_serve(deps)
|
||||||
|
# ingest / cluster / apply are wired in later tasks.
|
||||||
|
print(f"Command '{args.command}' is not implemented yet.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Mapping, Optional
|
||||||
|
|
||||||
|
REQUIRED = ["IMMICH_URL", "IMMICH_API_KEY"]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
def __init__(self, missing: list):
|
||||||
|
self.missing = missing
|
||||||
|
super().__init__(f"Missing required environment variables: {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
immich_url: str
|
||||||
|
immich_api_key: str
|
||||||
|
anthropic_api_key: str
|
||||||
|
data_dir: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_path(self) -> str:
|
||||||
|
return os.path.join(self.data_dir, "trip-cluster.db")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbs_dir(self) -> str:
|
||||||
|
return os.path.join(self.data_dir, "thumbs")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(env: Optional[Mapping] = None) -> Config:
|
||||||
|
env = env if env is not None else os.environ
|
||||||
|
missing = [k for k in REQUIRED if not (env.get(k) or "").strip()]
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(missing)
|
||||||
|
data_dir = (env.get("DATA_DIR") or "").strip() or os.path.join(os.getcwd(), "data")
|
||||||
|
return Config(
|
||||||
|
immich_url=env["IMMICH_URL"].strip().rstrip("/"),
|
||||||
|
immich_api_key=env["IMMICH_API_KEY"].strip(),
|
||||||
|
anthropic_api_key=(env.get("ANTHROPIC_API_KEY") or "").strip(),
|
||||||
|
data_dir=data_dir,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint("nav", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/health")
|
||||||
|
def health():
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def index():
|
||||||
|
return "trip-cluster"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import os
|
||||||
|
from flask import Blueprint, current_app, send_file, abort
|
||||||
|
|
||||||
|
bp = Blueprint("proxy", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/thumb/<asset_id>")
|
||||||
|
def thumb(asset_id):
|
||||||
|
cfg = current_app.config["APP_CONFIG"]
|
||||||
|
safe = os.path.basename(asset_id)
|
||||||
|
path = os.path.join(cfg.thumbs_dir, f"{safe}.jpg")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
abort(404)
|
||||||
|
return send_file(path, mimetype="image/jpeg")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint("review", __name__)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from app.config import load_config, ConfigError
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_raises():
|
||||||
|
with pytest.raises(ConfigError) as e:
|
||||||
|
load_config({"IMMICH_URL": "http://x"})
|
||||||
|
assert "IMMICH_API_KEY" in e.value.missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_optional_and_paths(tmp_path):
|
||||||
|
cfg = load_config({"IMMICH_URL": "http://x/", "IMMICH_API_KEY": "k",
|
||||||
|
"DATA_DIR": str(tmp_path)})
|
||||||
|
assert cfg.immich_url == "http://x" # trailing slash stripped
|
||||||
|
assert cfg.anthropic_api_key == "" # optional in M1
|
||||||
|
assert cfg.db_path == os.path.join(str(tmp_path), "trip-cluster.db")
|
||||||
|
assert cfg.thumbs_dir == os.path.join(str(tmp_path), "thumbs")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
from app import create_app
|
||||||
|
from app.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _app(tmp_path):
|
||||||
|
cfg = Config(immich_url="http://x", immich_api_key="k",
|
||||||
|
anthropic_api_key="", data_dir=str(tmp_path))
|
||||||
|
app = create_app(cfg)
|
||||||
|
app.config.update(TESTING=True)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health(tmp_path):
|
||||||
|
assert _app(tmp_path).test_client().get("/health").data == b"ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumb_served(tmp_path):
|
||||||
|
thumbs = os.path.join(str(tmp_path), "thumbs")
|
||||||
|
os.makedirs(thumbs, exist_ok=True)
|
||||||
|
with open(os.path.join(thumbs, "a.jpg"), "wb") as f:
|
||||||
|
f.write(b"\xff\xd8\xffjpeg")
|
||||||
|
client = _app(tmp_path).test_client()
|
||||||
|
r = client.get("/thumb/a")
|
||||||
|
assert r.status_code == 200 and r.mimetype == "image/jpeg"
|
||||||
|
assert _app(tmp_path).test_client().get("/thumb/missing").status_code == 404
|
||||||
Reference in New Issue
Block a user