add services

This commit is contained in:
2026-06-02 22:54:55 +02:00
parent f83e01380c
commit 28bcb6232f
9 changed files with 160 additions and 37 deletions
+1
View File
@@ -1 +1,2 @@
/camera/photos /camera/photos
.env
+66
View File
@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project overview
A two-component photo booth system running on Raspberry Pi:
1. **`camera/`** — Python/Flask camera server using `picamera2` (Raspberry Pi HQ Camera M12). Streams live MJPEG video and exposes a `/capture` endpoint that saves JPEG snapshots to `camera/photos/`.
2. **`ImmichUploader/`** — Laravel 13 PHP application that watches `camera/photos/` via `inotify`, and uploads new photos to a self-hosted [Immich](https://immich.app) instance via its HTTP API, then adds each asset to a specific album.
## Running the camera server
```bash
cd camera
python main.py
```
The server listens on port 5000 on all interfaces. Endpoints:
- `GET /` — live MJPEG stream viewer
- `GET /video_feed` — raw MJPEG stream
- `GET /capture` — saves current frame to `camera/photos/` and returns JSON
## Running the ImmichUploader
```bash
cd ImmichUploader
composer run setup # first-time: installs deps, generates key, runs migrations
composer run dev # starts all services: web server + queue worker + log viewer + vite
```
Or run the folder watcher and queue worker individually:
```bash
php artisan immich:watch-folder # watches FOLDER_PATH via inotify, dispatches jobs
php artisan queue:listen --tries=1 --timeout=0 # processes SendToImmichJob
```
Run tests:
```bash
composer run test
```
## Environment variables
**`ImmichUploader/.env`** (not the root `.env`):
- `IMMICH_URL` — base URL of the Immich instance (e.g. `https://photos.example.com`)
- `IMMICH_KEY` — Immich API key
- `FOLDER_PATH` — absolute path to the photos directory (matches `camera/photos/`)
The root `.env` is gitignored and only excluded `camera/photos` from tracking.
## Architecture notes
**Camera → Uploader flow:**
1. `camera/main.py` writes a JPEG to `camera/photos/<timestamp>.jpg` on `/capture`
2. `WatchFolder` artisan command (`inotify_add_watch` on `IN_CLOSE_WRITE | IN_MOVED_TO`) detects the new file
3. Dispatches `SendToImmichJob` to the database queue
4. The queue worker runs `SendToImmichJob::handle()` which POSTs the image to `/api/assets` then adds it to the hardcoded album via `PUT /api/albums/{albumId}/assets`
**Hardcoded values in `SendToImmichJob.php`:**
- `$albumId` — Immich album ID where photos are added
- `$id` (deviceAssetId) — fixed UUID used as the Immich device asset identifier
**`camera/` directory structure:** The `camera/` directory is itself a Python venv (has `pyvenv.cfg`), but the actual venv used by the app is `camera/myenv/` (contains Flask). `picamera2` is available from the system Python installation.
**ImmichUploader** uses SQLite (default, `database/database.sqlite`) for the queue, cache, and sessions — no separate database process needed.
+13 -16
View File
@@ -9,11 +9,13 @@ import os
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from flask import Flask, Response, jsonify, render_template, send_from_directory
from flask import Flask, Response, jsonify, render_template, send_from_directory
from libcamera import Transform
from picamera2 import Picamera2 from picamera2 import Picamera2
from picamera2.encoders import JpegEncoder from picamera2.encoders import JpegEncoder
from picamera2.outputs import FileOutput from picamera2.outputs import FileOutput
from PIL import Image
# ─── Configuration ──────────────────────────────────────────────────────────── # ─── Configuration ────────────────────────────────────────────────────────────
@@ -26,6 +28,7 @@ PHOTOS_DIR = os.path.join(os.path.dirname(__file__), "photos")
# ─── Buffer de frames thread-safe ───────────────────────────────────────────── # ─── Buffer de frames thread-safe ─────────────────────────────────────────────
class StreamOutput(io.BufferedIOBase): class StreamOutput(io.BufferedIOBase):
def __init__(self): def __init__(self):
self.frame = None self.frame = None
@@ -43,7 +46,8 @@ picam2 = Picamera2()
video_config = picam2.create_video_configuration( video_config = picam2.create_video_configuration(
main={"size": RESOLUTION, "format": "RGB888"}, main={"size": RESOLUTION, "format": "RGB888"},
controls={"FrameRate": FRAMERATE} transform=Transform(rotation=90),
controls={"FrameRate": FRAMERATE},
) )
picam2.configure(video_config) picam2.configure(video_config)
@@ -58,16 +62,14 @@ os.makedirs(PHOTOS_DIR, exist_ok=True)
# ─── Générateur MJPEG ───────────────────────────────────────────────────────── # ─── Générateur MJPEG ─────────────────────────────────────────────────────────
def generate_frames(): def generate_frames():
"""Génère un flux MJPEG frame par frame.""" """Génère un flux MJPEG frame par frame."""
while True: while True:
with output.condition: with output.condition:
output.condition.wait() output.condition.wait()
frame = output.frame frame = output.frame
yield ( yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n"
)
# ─── Application Flask ──────────────────────────────────────────────────────── # ─── Application Flask ────────────────────────────────────────────────────────
@@ -78,10 +80,7 @@ app = Flask(__name__)
@app.route("/") @app.route("/")
def index(): def index():
return render_template( return render_template(
"index.html", "index.html", width=RESOLUTION[0], height=RESOLUTION[1], quality=QUALITY
width=RESOLUTION[0],
height=RESOLUTION[1],
quality=QUALITY
) )
@@ -94,8 +93,8 @@ def capture():
filename = datetime.now().strftime("%Y%m%d_%H%M%S.jpg") filename = datetime.now().strftime("%Y%m%d_%H%M%S.jpg")
filepath = os.path.join(PHOTOS_DIR, filename) filepath = os.path.join(PHOTOS_DIR, filename)
with open(filepath, "wb") as f: image = Image.open(io.BytesIO(frame)).rotate(-90, expand=True)
f.write(frame) image.save(filepath, "JPEG", quality=QUALITY)
return jsonify({"status": "ok", "file": filename}) return jsonify({"status": "ok", "file": filename})
@@ -103,8 +102,7 @@ def capture():
@app.route("/photos") @app.route("/photos")
def photos(): def photos():
files = sorted( files = sorted(
[f for f in os.listdir(PHOTOS_DIR) if f.endswith(".jpg")], [f for f in os.listdir(PHOTOS_DIR) if f.endswith(".jpg")], reverse=True
reverse=True
)[:5] )[:5]
return jsonify(files) return jsonify(files)
@@ -118,8 +116,7 @@ def photo(filename):
def video_feed(): def video_feed():
"""Endpoint du flux MJPEG.""" """Endpoint du flux MJPEG."""
return Response( return Response(
generate_frames(), generate_frames(), mimetype="multipart/x-mixed-replace; boundary=frame"
mimetype="multipart/x-mixed-replace; boundary=frame"
) )
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+11 -1
View File
@@ -11,8 +11,18 @@ async function refreshGallery() {
} }
document.addEventListener("keydown", async (e) => { document.addEventListener("keydown", async (e) => {
if (e.key !== "c" && e.key !== "C") return; if (e.key !== ">") return;
const flash = document.getElementById("flash");
flash.classList.remove("fade");
flash.classList.add("active");
await new Promise((r) => setTimeout(r, 300));
await fetch("/capture"); await fetch("/capture");
flash.classList.remove("active");
flash.classList.add("fade");
refreshGallery(); refreshGallery();
}); });
+40 -7
View File
@@ -12,7 +12,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 20px; gap: 20px;
} }
@@ -38,6 +38,7 @@ h1 {
position: relative; position: relative;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
box-shadow: 0 0 40px rgba(0, 255, 136, 0.05); box-shadow: 0 0 40px rgba(0, 255, 136, 0.05);
transform: rotate(90deg) scale(1.5);
} }
.stream-container::before { .stream-container::before {
@@ -49,7 +50,25 @@ h1 {
color: #ff4444; color: #ff4444;
letter-spacing: 0.1em; letter-spacing: 0.1em;
z-index: 10; z-index: 10;
animation: blink 1.5s infinite; animation: blink 0.2s infinite;
}
.flash-overlay {
position: absolute;
inset: 0;
background: white;
opacity: 0;
pointer-events: none;
transition: opacity 0.08s ease-in;
}
.flash-overlay.active {
opacity: 0.6;
}
.flash-overlay.fade {
opacity: 0;
transition: opacity 0.2s ease-out;
} }
@keyframes blink { @keyframes blink {
@@ -58,18 +77,21 @@ h1 {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.2; opacity: 0;
} }
} }
img#stream { img#stream {
display: block; display: block;
max-width: 75vh; max-height: 95vh;
max-height: 90vw; max-width: 100vw;
} }
.gallery { .gallery {
display: flex; display: flex;
justify-content: center;
flex: 1;
margin-bottom: 5px;
gap: 8px; gap: 8px;
} }
@@ -80,8 +102,8 @@ img#stream {
} }
.gallery img { .gallery img {
width: 120px; width: 200px;
height: 80px; height: 300px;
object-fit: cover; object-fit: cover;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
opacity: 0.75; opacity: 0.75;
@@ -93,11 +115,22 @@ img#stream {
} }
footer { footer {
display: flex;
align-items: end;
font-size: 0.7rem; font-size: 0.7rem;
color: #333; color: #333;
letter-spacing: 0.1em; letter-spacing: 0.1em;
width: 100%;
} }
footer span { footer span {
color: #00ff88; color: #00ff88;
} }
.qrcode {
display: inline-block;
right: 16px;
width: 200px;
height: 200px;
margin-bottom: 30px;
}
+5 -7
View File
@@ -14,18 +14,16 @@
<div class="stream-container"> <div class="stream-container">
<img id="stream" src="/video_feed" alt="Camera stream"> <img id="stream" src="/video_feed" alt="Camera stream">
<div class="flash-overlay" id="flash"></div>
</div> </div>
<section class="gallery" id="gallery"> <footer class="flex">
<div class="gallery" id="gallery">
<p class="gallery-empty">Aucune photo pour l'instant</p> <p class="gallery-empty">Aucune photo pour l'instant</p>
</section> </div>
<img class="qrcode" src="{{ url_for('static', filename='qrcode.png') }}" alt="QR Code">
<footer>
Résolution <span>{{ width }}×{{ height }}</span>
Qualité <span>{{ quality }}%</span>
</footer> </footer>
<script src="{{ url_for('static', filename='script.js') }}"></script> <script src="{{ url_for('static', filename='script.js') }}"></script>
</body> </body>
</html> </html>
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Photo-booth queue
[Service]
Type=simple
ExecStart=php /home/pi/Scripts/photo_booth/ImmichUploader/artisan queue:work --tries=2 --timeout=60
[Install]
WantedBy=default.target
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Photo-booth queue
[Service]
Type=simple
ExecStart=php /home/pi/Scripts/photo_booth/ImmichUploader/artisan immich:watch-folder
[Install]
WantedBy=default.target