From 54945be0e344c8ac1a48a8e7768e53f90821aedd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 26 Jan 2026 12:38:38 -0800 Subject: [PATCH] Add immich-sync ansible role for photo library sync (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `immich_sync` role that syncs macOS Photos Library to Immich - Uses osxphotos to export photos with metadata to staging directory - Uses immich-cli (via Docker) to upload to Immich server - LaunchAgent schedules hourly syncs following mcquack pattern - API key fetched from 1Password in playbook pre_tasks ## Architecture ``` Photos Library → osxphotos export → ~/Pictures/immich-export/ → immich-cli upload → Immich ``` ## Prerequisites (manual) - Install osxphotos on indri: Add `"pipx:osxphotos" = "latest"` to `~/.config/mise/config.toml`, run `mise install` - Docker is already installed on indri ## Deployment and Testing - [ ] Dry run: `mise run provision-indri -- --tags immich_sync --check --diff` - [ ] Deploy: `mise run provision-indri -- --tags immich_sync` - [ ] Verify LaunchAgent: `ssh indri 'launchctl list | grep immich'` - [ ] Test manual sync: `ssh indri '~/bin/immich-sync.sh'` - [ ] Check logs: `ssh indri 'tail -50 ~/Library/Logs/mcquack.immich-sync.out.log'` - [ ] Verify photos in Immich at https://photos.ops.eblu.me 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/63 --- ansible/playbooks/indri.yml | 19 +++ ansible/roles/immich_sync/defaults/main.yml | 20 +++ ansible/roles/immich_sync/handlers/main.yml | 6 + ansible/roles/immich_sync/tasks/main.yml | 55 +++++++ .../templates/immich-sync.plist.j2 | 30 ++++ .../immich_sync/templates/immich-sync.sh.j2 | 150 ++++++++++++++++++ 6 files changed, 280 insertions(+) create mode 100644 ansible/roles/immich_sync/defaults/main.yml create mode 100644 ansible/roles/immich_sync/handlers/main.yml create mode 100644 ansible/roles/immich_sync/tasks/main.yml create mode 100644 ansible/roles/immich_sync/templates/immich-sync.plist.j2 create mode 100644 ansible/roles/immich_sync/templates/immich-sync.sh.j2 diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index 9ea46eb..a844011 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -78,6 +78,23 @@ no_log: true tags: [caddy] + # Immich API key for photo sync + - name: Fetch Immich API key + ansible.builtin.command: + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get ifcuzuceejqcjx7k7zd2gdsjem --fields api_key --reveal + delegate_to: localhost + register: _immich_api_key + changed_when: false + no_log: true + check_mode: false + tags: [immich_sync] + + - name: Set Immich API key fact + ansible.builtin.set_fact: + immich_sync_api_key: "{{ _immich_api_key.stdout }}" + no_log: true + tags: [immich_sync] + roles: - role: alloy tags: alloy @@ -99,3 +116,5 @@ tags: plex_metrics - role: caddy tags: caddy + - role: immich_sync + tags: immich_sync diff --git a/ansible/roles/immich_sync/defaults/main.yml b/ansible/roles/immich_sync/defaults/main.yml new file mode 100644 index 0000000..81899bd --- /dev/null +++ b/ansible/roles/immich_sync/defaults/main.yml @@ -0,0 +1,20 @@ +--- +# Immich server URL +immich_sync_url: "https://photos.ops.eblu.me" + +# Directory paths +immich_sync_export_dir: /Users/erichblume/Pictures/immich-export +immich_sync_log_dir: /Users/erichblume/Library/Logs +immich_sync_bin_dir: /Users/erichblume/bin + +# Schedule: hourly (StartInterval in seconds) +immich_sync_interval_seconds: 3600 + +# osxphotos export options +immich_sync_export_edited: true # Export edited versions +immich_sync_export_originals: true # Also export originals (as sidecars) +immich_sync_update_mode: true # Only export new/changed photos + +# immich-cli options +immich_sync_create_albums: true # Create albums from folder names +immich_sync_concurrency: 4 # Parallel uploads diff --git a/ansible/roles/immich_sync/handlers/main.yml b/ansible/roles/immich_sync/handlers/main.yml new file mode 100644 index 0000000..6408807 --- /dev/null +++ b/ansible/roles/immich_sync/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Reload immich-sync + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.immich-sync.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.immich-sync.plist + changed_when: true diff --git a/ansible/roles/immich_sync/tasks/main.yml b/ansible/roles/immich_sync/tasks/main.yml new file mode 100644 index 0000000..3e0d6af --- /dev/null +++ b/ansible/roles/immich_sync/tasks/main.yml @@ -0,0 +1,55 @@ +--- +# Note: osxphotos is installed via mise (pipx), not managed here. +# This role manages the sync wrapper script and scheduled LaunchAgent. +# +# Prerequisites: +# - osxphotos: Add to ~/.config/mise/config.toml on indri: +# [tools] +# "pipx:osxphotos" = "latest" +# Then run: mise install +# - Docker: Already installed on indri + +- name: Ensure export directory exists + ansible.builtin.file: + path: "{{ immich_sync_export_dir }}" + state: directory + mode: '0755' + +- name: Ensure bin directory exists + ansible.builtin.file: + path: "{{ immich_sync_bin_dir }}" + state: directory + mode: '0755' + +- name: Write Immich API key to secure file + ansible.builtin.copy: + content: "{{ immich_sync_api_key }}" + dest: ~/.immich-api-key + mode: '0600' + no_log: true + +- name: Deploy immich-sync wrapper script + ansible.builtin.template: + src: immich-sync.sh.j2 + dest: "{{ immich_sync_bin_dir }}/immich-sync.sh" + mode: '0755' + notify: Reload immich-sync + +- name: Deploy immich-sync LaunchAgent plist + ansible.builtin.template: + src: immich-sync.plist.j2 + dest: ~/Library/LaunchAgents/mcquack.eblume.immich-sync.plist + mode: '0644' + notify: Reload immich-sync + +- name: Check if immich-sync LaunchAgent is loaded + ansible.builtin.command: launchctl list mcquack.eblume.immich-sync + register: immich_sync_launchctl_check + changed_when: false + failed_when: false + +- name: Load immich-sync LaunchAgent if not loaded + ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.immich-sync.plist + when: immich_sync_launchctl_check.rc != 0 + changed_when: true + failed_when: false diff --git a/ansible/roles/immich_sync/templates/immich-sync.plist.j2 b/ansible/roles/immich_sync/templates/immich-sync.plist.j2 new file mode 100644 index 0000000..0c20474 --- /dev/null +++ b/ansible/roles/immich_sync/templates/immich-sync.plist.j2 @@ -0,0 +1,30 @@ + + + + + + KeepAlive + + Label + mcquack.eblume.immich-sync + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + HOME + /Users/erichblume + + ProgramArguments + + {{ immich_sync_bin_dir }}/immich-sync.sh + + RunAtLoad + + StandardErrorPath + {{ immich_sync_log_dir }}/mcquack.immich-sync.err.log + StandardOutPath + {{ immich_sync_log_dir }}/mcquack.immich-sync.out.log + StartInterval + {{ immich_sync_interval_seconds }} + + diff --git a/ansible/roles/immich_sync/templates/immich-sync.sh.j2 b/ansible/roles/immich_sync/templates/immich-sync.sh.j2 new file mode 100644 index 0000000..1895a12 --- /dev/null +++ b/ansible/roles/immich_sync/templates/immich-sync.sh.j2 @@ -0,0 +1,150 @@ +#!/bin/bash +# {{ ansible_managed }} +# +# Immich photo sync script +# Exports photos from macOS Photos Library and uploads to Immich +# +# Prerequisites: +# - osxphotos (installed via mise/pipx) +# - Docker (for immich-cli) +# - API key at ~/.immich-api-key + +set -euo pipefail + +# Explicit paths for LaunchAgent context (no PATH inheritance) +MISE=/opt/homebrew/opt/mise/bin/mise +DOCKER=/usr/local/bin/docker + +EXPORT_DIR="{{ immich_sync_export_dir }}" +IMMICH_URL="{{ immich_sync_url }}" +API_KEY_FILE="$HOME/.immich-api-key" +LOCKFILE="$HOME/.immich-sync.lock" +LOG_PREFIX="[immich-sync]" + +log() { + echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') $*" +} + +error() { + echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') ERROR: $*" >&2 +} + +# Lockfile management to prevent concurrent runs +acquire_lock() { + if [[ -f "$LOCKFILE" ]]; then + local lock_pid lock_time + lock_pid=$(cat "$LOCKFILE" 2>/dev/null | head -1) + lock_time=$(stat -f %Sm -t '%Y-%m-%d %H:%M:%S' "$LOCKFILE" 2>/dev/null || echo "unknown") + + # Check if the process is still running + if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then + error "Another sync is already running (PID: $lock_pid, started: $lock_time)" + error "Lockfile: $LOCKFILE" + error "If this is stale, remove the lockfile manually: rm $LOCKFILE" + exit 0 # Exit cleanly so LaunchAgent doesn't report failure + else + log "WARNING: Found stale lockfile from PID $lock_pid (process not running), removing it" + rm -f "$LOCKFILE" + fi + fi + + # Create lockfile with our PID + echo $$ > "$LOCKFILE" + log "Acquired lock (PID: $$)" +} + +release_lock() { + if [[ -f "$LOCKFILE" ]]; then + rm -f "$LOCKFILE" + log "Released lock" + fi +} + +# Ensure lock is released on exit (success or failure) +trap release_lock EXIT + +# Acquire lock before doing anything else +acquire_lock + +# Check prerequisites +if [[ ! -x "$MISE" ]]; then + error "mise not found at $MISE" + exit 1 +fi + +if [[ ! -x "$DOCKER" ]]; then + error "Docker not found at $DOCKER" + exit 1 +fi + +if [[ ! -f "$API_KEY_FILE" ]]; then + error "Immich API key not found at $API_KEY_FILE" + exit 1 +fi + +IMMICH_API_KEY=$(cat "$API_KEY_FILE") + +# Ensure export directory exists +mkdir -p "$EXPORT_DIR" + +log "Starting photo export from Photos Library to $EXPORT_DIR" + +# Export photos using osxphotos +# --directory: export directory structure by date +# --update: only export new/changed photos (incremental) +# --exiftool: write metadata to EXIF tags +# --edited-suffix: suffix for edited versions (default exports both original and edited) +# --download-missing: download from iCloud if needed +OSXPHOTOS_ARGS=( + export "$EXPORT_DIR" + --directory "{created.year}/{created.mm}-{created.mon}" +{% if immich_sync_update_mode %} + --update +{% endif %} + --exiftool + --download-missing + --edited-suffix "_edited" +{% if not immich_sync_export_edited %} + --skip-edited +{% endif %} +{% if not immich_sync_export_originals %} + --skip-original-if-edited +{% endif %} +) + +log "Running: mise x -- osxphotos ${OSXPHOTOS_ARGS[*]}" +if ! "$MISE" x -- osxphotos "${OSXPHOTOS_ARGS[@]}"; then + error "osxphotos export failed" + exit 1 +fi + +log "Photo export complete" +log "Starting upload to Immich at $IMMICH_URL" + +# Upload to Immich using immich-cli in Docker +# -r: recursive upload +# -a: create albums from folder names +# --concurrency: parallel uploads +IMMICH_CLI_ARGS=( + upload + --recursive +{% if immich_sync_create_albums %} + --album +{% endif %} + --concurrency {{ immich_sync_concurrency }} + /import +) + +log "Running: docker run immich-cli ${IMMICH_CLI_ARGS[*]}" +if ! "$DOCKER" run --rm \ + -v "$EXPORT_DIR:/import:ro" \ + -e IMMICH_INSTANCE_URL="$IMMICH_URL" \ + -e IMMICH_API_KEY="$IMMICH_API_KEY" \ + ghcr.io/immich-app/immich-cli:latest \ + "${IMMICH_CLI_ARGS[@]}"; then + error "immich-cli upload failed" + exit 1 +fi + +log "Upload to Immich complete" +log "Sync finished successfully"