diff --git a/apps/trip-cluster/app/__init__.py b/apps/trip-cluster/app/__init__.py index e69de29..08f3fa3 100644 --- a/apps/trip-cluster/app/__init__.py +++ b/apps/trip-cluster/app/__init__.py @@ -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 diff --git a/apps/trip-cluster/app/cli.py b/apps/trip-cluster/app/cli.py new file mode 100644 index 0000000..f81c172 --- /dev/null +++ b/apps/trip-cluster/app/cli.py @@ -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()) diff --git a/apps/trip-cluster/app/config.py b/apps/trip-cluster/app/config.py new file mode 100644 index 0000000..452504c --- /dev/null +++ b/apps/trip-cluster/app/config.py @@ -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, + ) diff --git a/apps/trip-cluster/app/routes/__init__.py b/apps/trip-cluster/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/trip-cluster/app/routes/nav.py b/apps/trip-cluster/app/routes/nav.py new file mode 100644 index 0000000..2245db8 --- /dev/null +++ b/apps/trip-cluster/app/routes/nav.py @@ -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" diff --git a/apps/trip-cluster/app/routes/proxy.py b/apps/trip-cluster/app/routes/proxy.py new file mode 100644 index 0000000..302e85c --- /dev/null +++ b/apps/trip-cluster/app/routes/proxy.py @@ -0,0 +1,14 @@ +import os +from flask import Blueprint, current_app, send_file, abort + +bp = Blueprint("proxy", __name__) + + +@bp.route("/thumb/") +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") diff --git a/apps/trip-cluster/app/routes/review.py b/apps/trip-cluster/app/routes/review.py new file mode 100644 index 0000000..de13b75 --- /dev/null +++ b/apps/trip-cluster/app/routes/review.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +bp = Blueprint("review", __name__) diff --git a/apps/trip-cluster/tests/test_config.py b/apps/trip-cluster/tests/test_config.py new file mode 100644 index 0000000..523280e --- /dev/null +++ b/apps/trip-cluster/tests/test_config.py @@ -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") diff --git a/apps/trip-cluster/tests/test_routes.py b/apps/trip-cluster/tests/test_routes.py new file mode 100644 index 0000000..18de583 --- /dev/null +++ b/apps/trip-cluster/tests/test_routes.py @@ -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