feat(trip-cluster): idempotent write-back + CLI apply with confirmation

This commit is contained in:
2026-06-27 17:35:58 +02:00
parent 5256df4117
commit 44c05444d3
3 changed files with 196 additions and 1 deletions
+26 -1
View File
@@ -41,6 +41,30 @@ def cmd_cluster(deps, *, gap_factor) -> int:
return 0
def cmd_apply(deps, *, yes) -> int:
cfg = deps["config"]
store = _store(cfg)
from app.writeback import apply_all, APPLYABLE
pending = [c for c in store.all_clusters() if c.status in APPLYABLE]
if not pending:
print("Nothing to apply.")
store.close()
return 0
if not yes:
ans = input(f"Apply {len(pending)} cluster decision(s) to Immich? [y/N] ")
if ans.strip().lower() not in ("y", "yes"):
print("Aborted.")
store.close()
return 1
client = _immich(deps, cfg)
results = apply_all(client, store)
store.close()
ok = sum(len(r["succeeded"]) for r in results)
bad = sum(len(r["failed"]) for r in results)
print(f"Applied {ok} tag write(s); {bad} failure(s) across {len(results)} cluster(s).")
return 0 if bad == 0 else 1
def cmd_serve(deps) -> int:
from app import create_app
create_app(deps["config"]).run(host="0.0.0.0", port=8084)
@@ -79,7 +103,8 @@ def main(argv=None) -> int:
tag=args.tag, subset=args.subset, full=args.full)
if args.command == "cluster":
return cmd_cluster(deps, gap_factor=args.gap_factor)
# apply is wired in a later task.
if args.command == "apply":
return cmd_apply(deps, yes=args.yes)
print(f"Command '{args.command}' is not implemented yet.")
return 1
+57
View File
@@ -0,0 +1,57 @@
from photoflow.immich import pipeline
APPLYABLE = ("approved", "non_trip", "skipped")
def _apply_tag(client, store, asset_ids, action, tag, tag_id):
todo = [a for a in asset_ids if not store.already_applied(a, action, tag)]
if not todo:
return [], []
try:
client.tag_assets(tag_id, todo)
except Exception as e: # noqa: BLE001 — recorded, surfaced
for a in todo:
store.log_writeback(a, action, tag, f"error:{e}")
return [], [(a, str(e)) for a in todo]
for a in todo:
store.log_writeback(a, action, tag, "ok")
return todo, []
def apply_cluster(client, store, cluster_id) -> dict:
c = store.get_cluster(cluster_id)
if c is None or c.status not in APPLYABLE:
return {"cluster_id": cluster_id, "status": c.status if c else None,
"succeeded": [], "failed": []}
included = [a.immich_id for a, m in store.cluster_members(cluster_id) if m.included]
succeeded, failed = [], []
if c.status == "approved":
tag = c.decided_name or c.suggested_name
ok, fail = _apply_tag(client, store, included, "trip", tag, client.upsert_tag(tag))
succeeded += ok
failed += fail
elif c.status == "non_trip":
ok, fail = _apply_tag(client, store, included, "non-trip", pipeline.NON_TRIP,
client.upsert_tag(pipeline.NON_TRIP))
succeeded += ok
failed += fail
# 'skipped': no content/non-trip tag, only processed below.
failed_ids = {i for i, _ in failed}
proc_targets = [a for a in included if a not in failed_ids]
if proc_targets:
_apply_tag(client, store, proc_targets, "processed", pipeline.PROCESSED,
client.upsert_tag(pipeline.PROCESSED))
for a in proc_targets:
if store.already_applied(a, "processed", pipeline.PROCESSED):
store.mark_processed(a)
return {"cluster_id": cluster_id, "status": c.status,
"succeeded": succeeded, "failed": failed}
def apply_all(client, store) -> list:
return [apply_cluster(client, store, c.id)
for c in store.all_clusters() if c.status in APPLYABLE]