Initial commit: Fuji photo processor pipeline

Automatic photo processing: Fuji X-H2 → FTP → Synology NAS → resize → Immich
- PollingObserver watches /incoming/ for new JPEGs
- Moves originals to /originals/YYYY/MM/
- Creates resized copies (1080x1920 @ 85%) with EXIF preserved
- SQLite tracking to prevent duplicate processing
- Deploy script for Synology NAS (docker run)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Roodenrijs
2026-03-08 18:38:18 +01:00
commit b9b5f53e53
6 changed files with 488 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.env
.env.*
*.egg-info/
dist/
build/
.venv/
venv/
*.db
.DS_Store

6
Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY processor.py .
CMD ["python", "-u", "processor.py"]

111
README.md Normal file
View File

@@ -0,0 +1,111 @@
# Fuji Photo Processor
Automatic photo processing pipeline for Fuji X-H2 photos: camera uploads via FTP to Synology NAS, files are automatically resized and organized, then picked up by Immich for library management.
## Architecture
```
┌─────────────┐ FTP ┌──────────────────┐ watchdog ┌─────────────┐
│ Fuji X-H2 │ ──────────→ │ Synology NAS │ ────────────→ │ Processor │
│ (camera) │ port 21 │ /volume2/photos/ │ polling │ container │
└─────────────┘ │ incoming/ │ └──────┬──────┘
└──────────────────┘ │
│ resize + move
┌─────────────┴─────────────┐
│ │
┌─────▼─────┐ ┌───────▼───────┐
│ /originals │ │ /processed │
│ YYYY/MM/ │ │ YYYY/MM/ │
│ (full-res) │ │ (1080px max) │
└────────────┘ └───────┬───────┘
┌───────▼───────┐
│ Immich │
│ external lib │
└───────────────┘
```
## Prerequisites
- Synology NAS with Docker (Container Manager) installed
- FTP server enabled on DSM
- Immich running (for photo management)
## Setup
### 1. FTP Server (DSM)
1. Open **Control Panel → File Services → FTP**
2. Enable FTP service on port **21**
3. Set passive port range: **50000-50100**
4. Create a dedicated user `fujiftp` with write access to `/volume2/photos/incoming`
### 2. Camera Configuration (Fuji X-H2)
Configure an FTP profile on the camera:
| Setting | Value |
|-----------------|--------------------------|
| Server IP | `192.168.175.141` |
| Port | `21` |
| Passive Mode | **ON** |
| Username | `fujiftp` |
| Password | *(your password)* |
| Upload Dir | `/incoming` |
| Auto Transfer | ON (or manual trigger) |
### 3. Deploy
```bash
./deploy.sh
```
The deploy script will:
- Create required directories on the NAS
- Transfer and build the Docker image on the NAS
- Start the container with appropriate volume mounts
### 4. Immich Integration
1. In Immich, go to **Administration → External Libraries**
2. Add a new library with import path: `/volume2/photos/processed`
3. Set scan interval (e.g., every 15 minutes)
4. Mount `/volume2/photos/processed` into the Immich container as a read-only volume
## Container Details
### Volumes
| Container Path | Host Path | Purpose |
|----------------|----------------------------------------|---------------------------|
| `/incoming` | `/volume2/photos/incoming` | FTP upload landing zone |
| `/originals` | `/volume2/photos/originals` | Full-resolution originals |
| `/processed` | `/volume2/photos/processed` | Resized copies for Immich |
| `/data` | `/volume2/docker/photo-processor/data` | SQLite tracking database |
### Environment Variables
| Variable | Default | Description |
|-----------------|---------|--------------------------------------|
| `POLL_INTERVAL` | `30` | Filesystem poll interval in seconds |
| `JPEG_QUALITY` | `85` | JPEG compression quality (1-100) |
| `MAX_WIDTH` | `1080` | Maximum width for resized images |
| `MAX_HEIGHT` | `1920` | Maximum height for resized images |
| `TZ` | `Europe/Amsterdam` | Container timezone |
## Troubleshooting
### Check container logs
```bash
ssh -i ../SynologyDocker/synology_ssh_key ssh@192.168.175.141 \
'sudo /usr/local/bin/docker logs -f photo-processor'
```
### Common Issues
- **Files not detected**: Check that the FTP user has write permissions to `/volume2/photos/incoming`. The processor uses polling (not inotify) so there may be up to a 30-second delay.
- **Permission denied on originals/processed**: Ensure the directories exist and are writable. The deploy script creates them automatically.
- **Duplicate filenames**: The processor tracks files by filename in SQLite. If you re-upload a file with the same name, it will be skipped. Delete the entry from `/volume2/docker/photo-processor/data/processed.db` to reprocess.
- **Container keeps restarting**: Check logs for Python errors. Common cause: missing directories or permission issues on volume mounts.
- **EXIF data lost**: The processor preserves EXIF data from the original. If EXIF is missing, the original file may not have contained it (check camera settings).

135
deploy.sh Executable file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Fuji Photo Processor Deployment Script for Synology NAS
# Deploys: photo-processor container (watches /incoming/ for JPEGs)
#
# This script runs from the LOCAL machine and SSHes into the NAS.
# Docker compose is not available on this Synology, so we use docker run.
set -euo pipefail
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SSH_KEY="$(cd "$SCRIPT_DIR/.." && pwd)/SynologyDocker/synology_ssh_key"
NAS_HOST="ssh@192.168.175.141"
NAS_DOCKER_DIR="/volume2/docker/photo-processor"
CONTAINER_NAME="photo-processor"
IMAGE_NAME="photo-processor"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# ---------------------------------------------------------------------------
# Logging helpers
# ---------------------------------------------------------------------------
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
# ---------------------------------------------------------------------------
# SSH / Docker wrappers
# ---------------------------------------------------------------------------
ssh_cmd() {
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$NAS_HOST" "$@"
}
docker_cmd() {
# Synology requires sudo for docker socket access
ssh_cmd "sudo /usr/local/bin/docker $*"
}
# ---------------------------------------------------------------------------
# Functions
# ---------------------------------------------------------------------------
create_directories() {
log_info "Creating directories on NAS..."
ssh_cmd "mkdir -p $NAS_DOCKER_DIR/data $NAS_DOCKER_DIR/build"
# Photos dirs may need elevated permissions - check and warn
if ! ssh_cmd "test -d /volume2/photos/incoming" 2>/dev/null; then
log_warn "/volume2/photos/ directories not found!"
log_warn "Create them via DSM File Station or as admin:"
log_warn " sudo mkdir -p /volume2/photos/{incoming,originals,processed}"
log_warn " sudo chown -R fujiftp:users /volume2/photos/incoming"
log_warn " sudo chmod 755 /volume2/photos/{originals,processed}"
exit 1
fi
}
build_image() {
log_info "Transferring build files to NAS..."
tar czf - -C "$SCRIPT_DIR" Dockerfile requirements.txt processor.py | \
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$NAS_HOST" \
"cd $NAS_DOCKER_DIR/build && tar xzf -"
log_info "Building $IMAGE_NAME image on NAS..."
docker_cmd build -t "$IMAGE_NAME" "$NAS_DOCKER_DIR/build"
}
stop_existing() {
log_info "Stopping existing container (if any)..."
docker_cmd stop "$CONTAINER_NAME" 2>/dev/null || true
docker_cmd rm "$CONTAINER_NAME" 2>/dev/null || true
}
start_container() {
log_info "Starting $CONTAINER_NAME container..."
docker_cmd run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--memory=256m \
-e TZ=Europe/Amsterdam \
-v /volume2/photos/incoming:/incoming \
-v /volume2/photos/originals:/originals \
-v /volume2/photos/processed:/processed \
-v "$NAS_DOCKER_DIR/data:/data" \
"$IMAGE_NAME"
}
show_status() {
echo ""
log_info "=== Container Status ==="
docker_cmd ps --filter "name=$CONTAINER_NAME" --format "'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
log_info "=== Fuji Photo Processor Deployment ==="
echo ""
# Verify SSH key exists
if [ ! -f "$SSH_KEY" ]; then
log_error "SSH key not found: $SSH_KEY"
log_error "Expected sibling repo at: $(cd "$SCRIPT_DIR/.." && pwd)/SynologyDocker/"
exit 1
fi
# Verify NAS connectivity
log_info "Testing NAS connectivity..."
if ! ssh_cmd "echo ok" >/dev/null 2>&1; then
log_error "Cannot connect to NAS at $NAS_HOST"
exit 1
fi
create_directories
build_image
stop_existing
start_container
show_status
echo ""
log_info "=== Deployment Complete ==="
log_info "Container: $CONTAINER_NAME"
log_info "Incoming: /volume2/photos/incoming (FTP upload target)"
log_info "Originals: /volume2/photos/originals/YYYY/MM/"
log_info "Processed: /volume2/photos/processed/YYYY/MM/"
echo ""
log_info "Check logs: ssh -i $SSH_KEY $NAS_HOST 'sudo /usr/local/bin/docker logs -f $CONTAINER_NAME'"
}
main "$@"

222
processor.py Normal file
View File

@@ -0,0 +1,222 @@
"""
Fuji Photo Processor
Watches /incoming/ for new JPEGs, moves originals and creates resized copies.
Designed for Fuji X-H2 → FTP → Synology NAS → Immich pipeline.
"""
import logging
import os
import shutil
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path
from PIL import Image
from watchdog.events import FileSystemEventHandler
from watchdog.observers.polling import PollingObserver
# ---------------------------------------------------------------------------
# Configuration from environment
# ---------------------------------------------------------------------------
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30"))
JPEG_QUALITY = int(os.environ.get("JPEG_QUALITY", "85"))
MAX_WIDTH = int(os.environ.get("MAX_WIDTH", "1080"))
MAX_HEIGHT = int(os.environ.get("MAX_HEIGHT", "1920"))
INCOMING_DIR = Path("/incoming")
ORIGINALS_DIR = Path("/originals")
PROCESSED_DIR = Path("/processed")
DB_PATH = Path("/data/processed.db")
JPEG_EXTENSIONS = {".jpg", ".jpeg"}
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
log = logging.getLogger("processor")
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
def init_db():
"""Initialize the SQLite tracking database."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute(
"""CREATE TABLE IF NOT EXISTS processed_files (
filename TEXT PRIMARY KEY,
processed_at TEXT NOT NULL,
original_path TEXT NOT NULL,
resized_path TEXT NOT NULL
)"""
)
conn.commit()
return conn
def is_already_processed(conn, filename):
"""Check if a file has already been processed."""
row = conn.execute(
"SELECT 1 FROM processed_files WHERE filename = ?", (filename,)
).fetchone()
return row is not None
def mark_processed(conn, filename, original_path, resized_path):
"""Record a file as processed."""
conn.execute(
"INSERT INTO processed_files (filename, processed_at, original_path, resized_path) "
"VALUES (?, ?, ?, ?)",
(filename, datetime.now().isoformat(), str(original_path), str(resized_path)),
)
conn.commit()
# ---------------------------------------------------------------------------
# Processing
# ---------------------------------------------------------------------------
def is_jpeg(path):
"""Check if a file path has a JPEG extension (case-insensitive)."""
return Path(path).suffix.lower() in JPEG_EXTENSIONS
def process_file(filepath, conn):
"""Move original and create a resized copy of a JPEG file."""
filepath = Path(filepath)
if not filepath.exists():
return
if not is_jpeg(filepath):
return
filename = filepath.name
if is_already_processed(conn, filename):
log.info("Skipping already processed: %s", filename)
return
try:
# Determine year/month from file modification time
mtime = filepath.stat().st_mtime
dt = datetime.fromtimestamp(mtime)
year_month = f"{dt.year}/{dt.month:02d}"
# Paths
original_dest = ORIGINALS_DIR / year_month / filename
resized_dest = PROCESSED_DIR / year_month / filename
# Create directories
original_dest.parent.mkdir(parents=True, exist_ok=True)
resized_dest.parent.mkdir(parents=True, exist_ok=True)
# Resize and save with EXIF preserved
with Image.open(filepath) as image:
exif_data = image.info.get("exif", b"")
image.thumbnail((MAX_WIDTH, MAX_HEIGHT), Image.LANCZOS)
save_kwargs = {"quality": JPEG_QUALITY}
if exif_data:
save_kwargs["exif"] = exif_data
image.save(str(resized_dest), "JPEG", **save_kwargs)
# Move original out of incoming
shutil.move(str(filepath), str(original_dest))
# Track in database
mark_processed(conn, filename, original_dest, resized_dest)
log.info("Processed: %s → originals/%s, processed/%s", filename, year_month, year_month)
except Exception:
log.exception("Error processing %s", filename)
# ---------------------------------------------------------------------------
# Watchdog handler
# ---------------------------------------------------------------------------
class PhotoHandler(FileSystemEventHandler):
"""Handles new JPEG files appearing in the incoming directory."""
def __init__(self, conn):
super().__init__()
self.conn = conn
def on_created(self, event):
if event.is_directory:
return
if is_jpeg(event.src_path):
log.info("New file detected: %s", event.src_path)
# Small delay to ensure file is fully written (FTP uploads)
time.sleep(2)
process_file(event.src_path, self.conn)
def on_moved(self, event):
"""Handle FTP clients that create temp files then rename."""
if event.is_directory:
return
if is_jpeg(event.dest_path):
log.info("Renamed file detected: %s", event.dest_path)
time.sleep(2)
process_file(event.dest_path, self.conn)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def scan_existing(conn):
"""Process any existing files in /incoming/ (catch-up after restart)."""
existing = list(INCOMING_DIR.glob("*"))
jpegs = [f for f in existing if f.is_file() and is_jpeg(f)]
if jpegs:
log.info("Found %d existing JPEG(s) in /incoming/, processing...", len(jpegs))
for filepath in jpegs:
process_file(filepath, conn)
else:
log.info("No existing files in /incoming/")
def main():
log.info("=== Fuji Photo Processor ===")
log.info("Config: poll=%ds, quality=%d, max_size=%dx%d",
POLL_INTERVAL, JPEG_QUALITY, MAX_WIDTH, MAX_HEIGHT)
log.info("Watching: %s", INCOMING_DIR)
conn = init_db()
log.info("Database initialized: %s", DB_PATH)
# Catch-up scan
scan_existing(conn)
# Start watching
handler = PhotoHandler(conn)
observer = PollingObserver(timeout=POLL_INTERVAL)
observer.schedule(handler, str(INCOMING_DIR), recursive=False)
observer.start()
log.info("Watching for new files (polling every %ds)...", POLL_INTERVAL)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
log.info("Shutting down...")
observer.stop()
observer.join()
conn.close()
if __name__ == "__main__":
main()

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Pillow==11.1.0
watchdog==4.0.2