diff --git a/.gitignore b/.gitignore index 0748732..42d40eb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,8 @@ test-results/ playwright-report/ tests/.auth/ +# travel-memories state +docs/immich-workflow/*.json + # OS .DS_Store diff --git a/docker-compose.yml b/docker-compose.yml index 8311883..50194c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,13 @@ services: - ./user:/var/www/html/user - ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini restart: unless-stopped + + travel-memories: + build: ./services/travel-memories + ports: + - "8082:8082" + volumes: + - ./docs/immich-workflow:/app/state + - ./user/pages:/app/pages + env_file: .env + user: "${UID}:${GID}" diff --git a/services/travel-memories/.gitignore b/services/travel-memories/.gitignore new file mode 100644 index 0000000..c7f8f75 --- /dev/null +++ b/services/travel-memories/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ diff --git a/services/travel-memories/Dockerfile b/services/travel-memories/Dockerfile new file mode 100644 index 0000000..be9065e --- /dev/null +++ b/services/travel-memories/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt && \ + playwright install chromium --with-deps +COPY app/ ./app/ +ENV FLASK_APP=app +ENV FLASK_RUN_HOST=0.0.0.0 +ENV FLASK_RUN_PORT=8082 +CMD ["flask", "run"] diff --git a/services/travel-memories/app/__init__.py b/services/travel-memories/app/__init__.py new file mode 100644 index 0000000..b4344aa --- /dev/null +++ b/services/travel-memories/app/__init__.py @@ -0,0 +1,21 @@ +import os +from flask import Flask + +def create_app(state_dir=None, pages_dir=None): + app = Flask(__name__) + app.config["STATE_DIR"] = state_dir or os.environ.get("STATE_DIR", "/app/state") + app.config["PAGES_DIR"] = pages_dir or os.environ.get("PAGES_DIR", "/app/pages") + app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "") + app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "") + + from .routes import albums, proxy, notes, nav + app.register_blueprint(albums.bp) + app.register_blueprint(proxy.bp) + app.register_blueprint(notes.bp) + app.register_blueprint(nav.bp) + + @app.get("/health") + def health(): + return {"ok": True} + + return app diff --git a/services/travel-memories/app/routes/__init__.py b/services/travel-memories/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/travel-memories/app/routes/albums.py b/services/travel-memories/app/routes/albums.py new file mode 100644 index 0000000..dfcf974 --- /dev/null +++ b/services/travel-memories/app/routes/albums.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +bp = Blueprint("albums", __name__) diff --git a/services/travel-memories/app/routes/nav.py b/services/travel-memories/app/routes/nav.py new file mode 100644 index 0000000..a0cf5eb --- /dev/null +++ b/services/travel-memories/app/routes/nav.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +bp = Blueprint("nav", __name__) diff --git a/services/travel-memories/app/routes/notes.py b/services/travel-memories/app/routes/notes.py new file mode 100644 index 0000000..2d49db2 --- /dev/null +++ b/services/travel-memories/app/routes/notes.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +bp = Blueprint("notes", __name__) diff --git a/services/travel-memories/app/routes/proxy.py b/services/travel-memories/app/routes/proxy.py new file mode 100644 index 0000000..e3ff80e --- /dev/null +++ b/services/travel-memories/app/routes/proxy.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +bp = Blueprint("proxy", __name__) diff --git a/services/travel-memories/pytest.ini b/services/travel-memories/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/services/travel-memories/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/services/travel-memories/requirements.txt b/services/travel-memories/requirements.txt new file mode 100644 index 0000000..ed586c6 --- /dev/null +++ b/services/travel-memories/requirements.txt @@ -0,0 +1,5 @@ +flask==3.1.0 +requests==2.32.3 +pytest==8.3.4 +pytest-playwright==0.6.2 +pytest-httpserver==1.1.0 diff --git a/services/travel-memories/tests/conftest.py b/services/travel-memories/tests/conftest.py new file mode 100644 index 0000000..444775a --- /dev/null +++ b/services/travel-memories/tests/conftest.py @@ -0,0 +1,101 @@ +import json +import os +import shutil +import threading +import time +from pathlib import Path + +import pytest +from werkzeug.serving import make_server + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +TINY_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d4948445200000001000000010806" + "0000001f15c4890000000a4944415478016360000000020001e2" + "21bc330000000049454e44ae426082" +) + +MOCK_ALBUMS = [ + { + "id": "album-1", + "albumName": "Central Asia 2023", + "assetCount": 3, + "albumThumbnailAssetId": "asset-1", + } +] + +MOCK_ALBUM_DETAIL = { + "id": "album-1", + "albumName": "Central Asia 2023", + "assets": [ + {"id": "asset-1", "originalFileName": "IMG_001.jpg", + "localDateTime": "2023-09-05T09:03:00"}, + {"id": "asset-2", "originalFileName": "IMG_002.jpg", + "localDateTime": "2023-09-05T14:30:00"}, + {"id": "asset-3", "originalFileName": "IMG_003.jpg", + "localDateTime": "2023-09-06T10:00:00"}, + ], +} + + +@pytest.fixture(scope="session") +def httpserver_listen_address(): + return ("127.0.0.1", 8099) + + +@pytest.fixture(scope="session") +def mock_immich(make_httpserver): + server = make_httpserver + server.expect_request("/api/albums").respond_with_json(MOCK_ALBUMS) + server.expect_request("/api/albums/album-1").respond_with_json(MOCK_ALBUM_DETAIL) + for asset_id in ["asset-1", "asset-2", "asset-3"]: + server.expect_request( + f"/api/assets/{asset_id}/thumbnail" + ).respond_with_data(TINY_PNG, content_type="image/png") + server.expect_request( + f"/api/assets/{asset_id}/original" + ).respond_with_data(TINY_PNG, content_type="image/jpeg") + return server + + +@pytest.fixture(scope="session") +def state_dir(tmp_path_factory): + return tmp_path_factory.mktemp("state") + + +@pytest.fixture(scope="session") +def pages_dir(tmp_path_factory): + return tmp_path_factory.mktemp("pages") + + +@pytest.fixture(scope="session") +def flask_app(state_dir, pages_dir, mock_immich): + os.environ["IMMICH_URL"] = f"http://127.0.0.1:8099" + os.environ["IMMICH_API_KEY"] = "test-key" + from app import create_app + return create_app(state_dir=str(state_dir), pages_dir=str(pages_dir)) + + +@pytest.fixture(scope="session") +def base_url(flask_app): + server = make_server("127.0.0.1", 8083, flask_app) + t = threading.Thread(target=server.serve_forever) + t.daemon = True + t.start() + time.sleep(0.2) + yield "http://127.0.0.1:8083" + server.shutdown() + + +@pytest.fixture() +def seed_state(state_dir): + """Copy a fixture JSON into the state dir; return the album_id.""" + def _seed(fixture_name: str) -> str: + src = FIXTURES_DIR / f"{fixture_name}.json" + with open(src) as f: + data = json.load(f) + dst = Path(state_dir) / f"{data['album_id']}.json" + shutil.copy(src, dst) + return data["album_id"] + return _seed diff --git a/services/travel-memories/tests/fixtures/phase2_state.json b/services/travel-memories/tests/fixtures/phase2_state.json new file mode 100644 index 0000000..a9cb900 --- /dev/null +++ b/services/travel-memories/tests/fixtures/phase2_state.json @@ -0,0 +1,18 @@ +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "triage", + "phases_completed": [], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "untagged", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "untagged", "order": 1}, + {"id": "asset-3", "original_filename": "IMG_003.jpg", + "local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2} + ], + "groups": [], + "notes": "" +} diff --git a/services/travel-memories/tests/fixtures/phase3_state.json b/services/travel-memories/tests/fixtures/phase3_state.json new file mode 100644 index 0000000..bc5f632 --- /dev/null +++ b/services/travel-memories/tests/fixtures/phase3_state.json @@ -0,0 +1,18 @@ +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "curate", + "phases_completed": ["triage"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}, + {"id": "asset-3", "original_filename": "IMG_003.jpg", + "local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2} + ], + "groups": [], + "notes": "" +} diff --git a/services/travel-memories/tests/fixtures/phase4_state.json b/services/travel-memories/tests/fixtures/phase4_state.json new file mode 100644 index 0000000..e520db3 --- /dev/null +++ b/services/travel-memories/tests/fixtures/phase4_state.json @@ -0,0 +1,16 @@ +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "group", + "phases_completed": ["triage", "curate"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} + ], + "groups": [], + "notes": "I remember the airport was chaos." +} diff --git a/services/travel-memories/tests/fixtures/phase5_state.json b/services/travel-memories/tests/fixtures/phase5_state.json new file mode 100644 index 0000000..589f593 --- /dev/null +++ b/services/travel-memories/tests/fixtures/phase5_state.json @@ -0,0 +1,29 @@ +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "write", + "phases_completed": ["triage", "curate", "group"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} + ], + "groups": [ + { + "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", + "title": "", "body": "", "location_city": "", "location_country": "", + "date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "", + "status": "draft" + }, + { + "id": "g2", "photo_ids": ["asset-2"], "entry_type": "story", + "title": "", "body": "", "location_city": "", "location_country": "", + "date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "", + "status": "draft" + } + ], + "notes": "I remember the airport was chaos." +} diff --git a/services/travel-memories/tests/fixtures/phase6_state.json b/services/travel-memories/tests/fixtures/phase6_state.json new file mode 100644 index 0000000..ad20062 --- /dev/null +++ b/services/travel-memories/tests/fixtures/phase6_state.json @@ -0,0 +1,31 @@ +{ + "album_id": "album-1", + "album_name": "Central Asia 2023", + "grav_trip_slug": "central-asia-2023", + "phase": "export", + "phases_completed": ["triage", "curate", "group", "write"], + "phase_stale": [], + "photos": [ + {"id": "asset-1", "original_filename": "IMG_001.jpg", + "local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0}, + {"id": "asset-2", "original_filename": "IMG_002.jpg", + "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} + ], + "groups": [ + { + "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", + "title": "Arrival in Almaty", "body": "Chaos at the airport.", + "location_city": "Almaty", "location_country": "Kazakhstan", + "date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "", + "status": "written" + }, + { + "id": "g2", "photo_ids": ["asset-2"], "entry_type": "story", + "title": "The Market", "body": "Colours everywhere.", + "location_city": "Almaty", "location_country": "Kazakhstan", + "date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block", + "status": "skipped" + } + ], + "notes": "" +} diff --git a/services/travel-memories/tests/test_smoke.py b/services/travel-memories/tests/test_smoke.py new file mode 100644 index 0000000..77ca68c --- /dev/null +++ b/services/travel-memories/tests/test_smoke.py @@ -0,0 +1,3 @@ +def test_health(base_url, page): + page.goto(f"{base_url}/health") + assert "ok" in page.content()