From 39d19cf2f8c848fdc4dce41c29ac10fd439565d5 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 21 Jun 2026 16:18:38 +0200 Subject: [PATCH] feat: Phase 1 album selection with resume/start-over Implements GET / listing Immich albums with resume badge, POST /select creating TripState and redirecting to /triage; graceful error display when Immich is unreachable. Co-Authored-By: Claude Sonnet 4.6 --- services/travel-memories/app/routes/albums.py | 71 ++++++++++++++++++- .../travel-memories/app/templates/phase1.html | 55 ++++++++++++++ services/travel-memories/tests/test_phase1.py | 30 ++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 services/travel-memories/app/templates/phase1.html create mode 100644 services/travel-memories/tests/test_phase1.py diff --git a/services/travel-memories/app/routes/albums.py b/services/travel-memories/app/routes/albums.py index 1cdd45c..1119470 100644 --- a/services/travel-memories/app/routes/albums.py +++ b/services/travel-memories/app/routes/albums.py @@ -1,9 +1,76 @@ -from flask import Blueprint, current_app, render_template, request -from app.state import load_state +from pathlib import Path + +from flask import Blueprint, current_app, redirect, render_template, request +from app.immich import ImmichClient +from app.state import TripState, Photo, load_state, save_state bp = Blueprint("albums", __name__) +def _client(): + return ImmichClient(current_app.config["IMMICH_URL"], + current_app.config["IMMICH_API_KEY"]) + + +@bp.get("/") +def index(): + try: + albums = _client().list_albums() + error = None + except ConnectionError as e: + albums = [] + error = str(e) + state_dir = Path(current_app.config["STATE_DIR"]) + for album in albums: + album["has_state"] = (state_dir / f"{album['id']}.json").exists() + return render_template("phase1.html", albums=albums, error=error, + current_phase="", album_id=None, + phase_stale=[], notes_content="") + + +@bp.post("/select") +def select(): + album_ids = request.form.getlist("album_ids[]") + grav_trip_slug = request.form["grav_trip_slug"].strip() + start_over = request.form.get("start_over") == "1" + + if len(album_ids) == 1: + primary_id = album_ids[0] + else: + primary_id = "__merged__" + "_".join(sorted(album_ids)) + + existing = load_state(primary_id, current_app) + if existing and not start_over: + return redirect(f"/{existing.phase}?album_id={primary_id}") + + # Fetch and merge assets, deduplicating by asset ID + all_assets = {} + album_name_parts = [] + for aid in album_ids: + album = _client().get_album(aid) + album_name_parts.append(album["albumName"]) + for asset in album["assets"]: + if asset["id"] not in all_assets: + all_assets[asset["id"]] = asset + + photos = [ + Photo(id=a["id"], original_filename=a["originalFileName"], + local_datetime=a["localDateTime"]) + for a in sorted(all_assets.values(), key=lambda x: x["localDateTime"]) + ] + for i, p in enumerate(photos): + p.order = i + + state = TripState( + album_id=primary_id, + album_name=", ".join(album_name_parts), + grav_trip_slug=grav_trip_slug, + photos=photos, + ) + save_state(state, current_app) + return redirect(f"/triage?album_id={primary_id}") + + # TODO(task-6): replace this stub with the real triage route @bp.get("/triage") def triage(): diff --git a/services/travel-memories/app/templates/phase1.html b/services/travel-memories/app/templates/phase1.html new file mode 100644 index 0000000..41c33fd --- /dev/null +++ b/services/travel-memories/app/templates/phase1.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} +
+

Select Album

+ + {% if error %} +
+ Cannot reach Immich: {{ error }} + Retry +
+ {% endif %} + +
+
+ {% for album in albums %} + + {% endfor %} +
+ +
+ + +
+ + +
+ + +
+
+
+{% endblock %} diff --git a/services/travel-memories/tests/test_phase1.py b/services/travel-memories/tests/test_phase1.py new file mode 100644 index 0000000..4f23b47 --- /dev/null +++ b/services/travel-memories/tests/test_phase1.py @@ -0,0 +1,30 @@ +def test_album_list_renders(base_url, page): + page.goto(base_url) + assert page.locator(".album-card").count() == 1 + assert "Central Asia 2023" in page.inner_text(".album-card") + + +def test_select_single_album_creates_state(base_url, page): + page.goto(base_url) + page.locator(".album-card input[type=checkbox]").first.check() + page.fill("#grav-slug", "central-asia-2023") + page.locator("button[type=submit]").click() + page.wait_for_url("**/triage**") + assert "album_id=album-1" in page.url + + +def test_resume_prompt_shown_for_existing_state(base_url, page, seed_state): + seed_state("phase2_state") + page.goto(base_url) + assert page.locator("[data-album-id=album-1] .resume-badge").is_visible() + + +def test_immich_unreachable_shows_error(base_url, page, monkeypatch): + import app.routes.albums as a + orig = a.ImmichClient + class BrokenClient: + def __init__(self, *a, **k): pass + def list_albums(self): raise ConnectionError("down") + monkeypatch.setattr(a, "ImmichClient", BrokenClient) + page.goto(base_url) + assert page.locator(".alert-error").is_visible()