Event photo and video sharing powered by face recognition. Find yourself in every moment.
Organizers create events, bulk-upload photos/videos, and share a link. Attendees open the link (entering a passcode if the event is private), browse the gallery with All / Photos / Videos tabs, take a selfie to find their own photos, and download singles or zips. Attendees never need an account — only organizers log in.
Interactive docs: GET /docs.
Frontend splits 1000 files into chunks of ~25, POSTs chunks in parallel
↓
FastAPI streams each file to TempStorage, hashing while writing (one I/O pass)
↓
Per chunk: one dedupe query on (event_id, file_hash), one bulk insert
(conflict → row-by-row retry, race duplicates dropped, temp cleaned)
↓
Celery tasks dispatched immediately per chunk (images + videos split)
↓
Image: RetinaFace detect (downscaled to 1600px) → ArcFace 512-d embeddings
→ face_embeddings rows (pgvector) → promote via save_from_path()
Video: promote only (no face pipeline)
↓
Redis event:{id}:progress + pub/sub → SSE GET /media/{id}/upload-progress
Attendee uploads with require_upload_approval=true land as PENDING_APPROVAL (hidden) until POST .../approve. Rejected items go to the bin.
Selfie → extract_single_embedding (largest face) → pgvector cosine search
SQL: MAX(1 - (embedding <=> :q)) per media, threshold 0.6, limit 1000
↓
Matches + embedding stored in Redis under scan_token (TTL 2h, default 7200s)
Embedding never touches the users table
↓
GET /gallery/anonymous?token=&type=&page= → paginated MediaCards with match_score
POST /gallery/flag {media_id, reason, scan_token?, note?} stores a row in media_flags (no user). A flag means "bad match", never deletes the photo. Organizers read GET /gallery/flags.
DELETE /media/{id}/{media_id} sets is_deleted + deleted_at. Bin:
GET /media/{id}/bin/events (sidebar) → GET /media/{id}/bin → restore / hard-delete / empty.
app/
api/router/ auth.py event.py media.py faces.py gallery.py downloads.py admin.py
repositories/ media_repo.py flag_repo.py event_repo.py admin_repo.py # pure DB, no logic
services/ media_service.py (ingest) face_service.py search_service.py
gallery_service.py (anon store + flags) download_service.py
event_service.py auth/ storage_service/
models/ user.py event.py media.py media_flag.py face_embedding.py
schemas/ event.py media.py face.py gallery.py
core/ pagination.py (Pydantic inherit) schemas.py (ApiResponse[T] + meta)
workers/ media_tasks.py celery_app.py
alembic/versions/ 0001_enable_extensions 2f1be46638be_models 3a1b2c4d5e6f_soft_delete_flags
API_CONTRACT.md # hand this to the frontend agent
Key patterns:
- Pagination: inherit
PaginationParams(page,page_size,offsetproperty,build_meta(total)). Router takesAnnotated[MyParams, Query()]→?page=&page_size=&type=in one object. Lists returnApiResponse[data, meta]. - ApiResponse:
ApiResponse[T](PEP 695) withmessage,data,meta: PaginationMeta | None. - Repos vs services: repos do
SELECT ... WHERE event + not deleted + type + offset/limit; services do ingest, face ML, Redis, storage, zip streaming. - Storage:
TempStorage.stage_uploadalways first;BaseStorage.save_from_pathpromotes to local now, Cloudflare R2 later (same call shape).
cp .env.example .env
uv sync
docker compose up -d # postgres + redis (see docker-compose.yml)
uv run alembic upgrade head
uv run uvicorn app.main:app --reload --port 8000
uv run celery -A app.workers.celery_app worker --loglevel=info --concurrency=2Docs: http://localhost:8000/docs · Health: GET /health.
- Frontend: chunks of 25, 4–6 parallel POSTs, one SSE stream for progress + ETA.
- Backend:
INGEST_CHUNK_SIZE=25,INGEST_CONCURRENCY=8; Celery--concurrency= CPU cores (face detection is CPU-bound, model warmed once per worker). - Deduped by SHA256 per event; response reports
accepted / skipped / errors.
GET /media/{id}/counts → {total, images, videos} for badges.
GET /media/{id}?type=all|image|video&page=&page_size= for each tab.
| Var | Default | Purpose |
|---|---|---|
SECRET_KEY |
— | JWT signing (set a strong value) |
POSTGRES_HOST/PORT/DB/USER/PASSWORD |
localhost/5432/gallerai | asyncpg + pgvector |
REDIS_HOST/PORT/DB |
localhost/6379/0 | Celery broker, progress pub/sub, scan tokens, cache |
STORAGE_BACKEND |
local | local now, cloudflare later |
TEMP_STORAGE_ROOT / LOCAL_STORAGE_FINAL_ROOT |
./storage/tmp, ./storage/final | staging vs permanent (separate dirs) |
FACE_SIMILARITY_THRESHOLD |
0.6 | stricter = fewer false positives |
FACE_DETECTION_CONFIDENCE / FACE_MIN_SIZE |
0.9 / 80 | quality filters |
ANONYMOUS_SCAN_TTL_SECONDS |
7200 | scan_token lifetime |
GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI |
— | creator Google login |
SMTP_* / FRONTEND_URL |
— | transactional email + links |
- No attendee accounts. Earlier drafts had
UserEventGallery, claim-into-account,User.face_embedding. Deleted in3a1b2c4d5e6f: attendees are anonymous + passcode cookie + scan_token. Only organizers haveUserrows (plus co-organizers viaevent_members). - Nullable
storage_backend:NULL= not promoted yet; local is a real permanent backend, not scratch. - Temp vs final split: every upload stages first, detection runs on temp, promotion copies to final, temp deleted by one path.
- Event-scoped progress: keyed by
event_id, not upload id — reconnect-safe, aggregates concurrent uploaders. - Model warmup once per worker (
worker_process_init), Celery concurrency = parallelism (no nested process pools). is_privatelegacy: column kept but ignored for visibility; everything shows unless soft-deleted.- Admin:
is_adminBearer gates/api/v1/admin/*(users, events, flags, settings, stats). Archive/restore are reversible; hard deletes wipe storage too. - Serve through app:
GET .../serve/{key}checks access + deleted, proxies local bytes, 302s to CDN once cloud is configured.