repo cleanup: retire legacy mule-image stack, lock PhotoPrism UI to loopback
Delete the old Python+React mule-image stack (backend/, frontend/,
docker-compose.yml, mulita.yml, .env*) plus the one-shot migration and
sample dirs (migrate/, photos-sample/, photovault-app-prompt.md). Only
the PhotoPrism + Go sidecar + SvelteKit web stack remains, so drop the
".photoprism." qualifier from the compose+env filenames.
Bind PhotoPrism's port to 127.0.0.1 so the user-facing surface is just
the SvelteKit web/ app; admin reaches PP's UI via SSH tunnel. Flatten
PHOTOPRISM_INDEX_WORKERS' nested default (podman-compose's interpolator
doesn't expand ${A:-${B:-…}}). Rewrite README for the current stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
.env
22
.env
@@ -1,22 +0,0 @@
|
||||
# Mulita / PhotoVault local environment.
|
||||
# See .env.example for the full list of knobs and their docs.
|
||||
|
||||
# REQUIRED — host path to your photo library.
|
||||
PHOTO_DIRS=/mnt/library/homecloud/admin/files/
|
||||
|
||||
# Ports — change if 3000 / 8001 collide with other services on the host.
|
||||
FRONTEND_PORT=3000
|
||||
BACKEND_PORT=8001
|
||||
REDIS_PORT=6379
|
||||
|
||||
# CORS — wildcard for local dev. Lock down for real deployments.
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# Logging + timezone.
|
||||
LOG_LEVEL=INFO
|
||||
TZ=UTC
|
||||
|
||||
# Celery worker pools — split worker-light (IO) and worker-vision (CPU).
|
||||
# Defaults target a 6-core / 16 GB host.
|
||||
CELERY_LIGHT_CONCURRENCY=2
|
||||
CELERY_VISION_CONCURRENCY=5
|
||||
165
.env.example
165
.env.example
@@ -1,113 +1,70 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mulita / PhotoVault — example environment file
|
||||
# Example environment file. Copy to `.env` and adjust.
|
||||
#
|
||||
# Copy this file to `.env` and adjust the values for your setup. Every key
|
||||
# below has a sensible default in docker-compose.yml, so you only need to
|
||||
# uncomment the ones you actually want to change.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# podman-compose --env-file .env \
|
||||
# -f docker-compose.yml -f docker-compose.podman.yml up -d
|
||||
|
||||
|
||||
# ── REQUIRED ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host path to your photo library. The compose file mounts this at /photos
|
||||
# inside the backend + worker containers. The backend creates a default
|
||||
# source root pointing at /photos on first boot, so once this is set the
|
||||
# library is scanned with zero further configuration.
|
||||
# Host path to your photo library. PhotoPrism reads this in place and
|
||||
# writes EXIF backwrites next to originals (when PP_ORIGINALS_MODE=rw).
|
||||
PHOTO_DIRS=/mnt/library/homecloud/admin/files/
|
||||
|
||||
# Bootstrap admin password. The first PhotoPrism boot creates an `admin`
|
||||
# account with this password. Rotate after first login from the UI.
|
||||
PP_ADMIN_PASSWORD=please-change-me
|
||||
|
||||
# MariaDB passwords. Generate with `openssl rand -hex 24`.
|
||||
PP_DB_PASSWORD=please-change-me
|
||||
PP_DB_ROOT_PASSWORD=please-change-me
|
||||
|
||||
|
||||
# ── OPTIONAL ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Loopback host port for PhotoPrism's API (and UI, if you tunnel to it).
|
||||
# Vite proxies /api/v1/* here and the host-mode sidecar reaches it on
|
||||
# localhost. Not published on the public interface.
|
||||
PP_PORT=2342
|
||||
|
||||
# Site URL — used for share links, OIDC redirect URI, and reverse-proxy aware
|
||||
# URL generation. Set to the public hostname once the proxy is in front.
|
||||
PP_SITE_URL=http://localhost:2342/
|
||||
|
||||
# Auth mode — "password" for username/password (default), "public" for an
|
||||
# unauthenticated kiosk mode (don't use this on a multi-user library).
|
||||
PP_AUTH_MODE=password
|
||||
|
||||
# Library mount mode. "rw" allows rename / folder mutations / EXIF backwrite;
|
||||
# "ro" is safe-for-archives but disables those sidecar endpoints. Set in
|
||||
# lockstep with PP_READONLY below.
|
||||
PP_ORIGINALS_MODE=rw
|
||||
PP_READONLY=false
|
||||
|
||||
# UID/GID inside the PhotoPrism container. Set these to the host UID/GID that
|
||||
# owns ${PHOTO_DIRS}. `id -u` and `id -g`.
|
||||
PP_UID=1000
|
||||
PP_GID=1000
|
||||
|
||||
|
||||
# ── OIDC SSO (Authentik or equivalent) ───────────────────────────────────────
|
||||
# Leave blank to keep OIDC dormant. Fill in to enable the "Sign in with OIDC"
|
||||
# button on the login page; OIDC_REGISTER=true auto-creates accounts at role
|
||||
# `user` (override to `admin` to grant full access on first SSO login).
|
||||
#
|
||||
# Examples:
|
||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
PHOTO_DIRS=./photos
|
||||
# The compose file reads these and maps them to PhotoPrism's actual env-var
|
||||
# names (PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER) — see the
|
||||
# comment in docker-compose.yml. The PhotoPrism callback URI is auto-derived
|
||||
# from PP_SITE_URL; do not set it manually.
|
||||
|
||||
# OIDC_PROVIDER_NAME=Authentik
|
||||
# OIDC_ISSUER_URL=https://auth.example.com/application/o/photoprism/
|
||||
# OIDC_CLIENT_ID=...
|
||||
# OIDC_CLIENT_SECRET=...
|
||||
# OIDC_SCOPES=openid profile email
|
||||
# OIDC_REGISTER=true
|
||||
# OIDC_ROLE=user
|
||||
|
||||
|
||||
# ── PORTS ────────────────────────────────────────────────────────────────────
|
||||
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host port the SPA is served on. Browse to http://<host>:<FRONTEND_PORT>/.
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# Host port for the backend API. Almost never needed directly — the frontend
|
||||
# nginx proxies /api/ to the backend over the internal compose network. Kept
|
||||
# exposed for debugging / curl.
|
||||
BACKEND_PORT=8001
|
||||
|
||||
# Redis host port. Internal services reach Redis on its container name; this
|
||||
# is just for local debugging.
|
||||
REDIS_PORT=6379
|
||||
|
||||
|
||||
# ── AUTH ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Secret key used to sign JWT tokens. Generate a strong random value for
|
||||
# production (e.g. `openssl rand -base64 32`). The default is a deterministic
|
||||
# placeholder acceptable only for local/homelab use.
|
||||
# SECRET_KEY=change-me-to-a-random-string
|
||||
|
||||
# How long access and refresh tokens stay valid. Access tokens are short-lived
|
||||
# and silently refreshed by the frontend; refresh tokens let a session survive
|
||||
# across browser restarts.
|
||||
# ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
# REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Comma-separated list of allowed origins for direct browser access to the
|
||||
# backend. Same-origin requests through the nginx / vite proxy never trip
|
||||
# CORS, so this only matters when something hits the backend port directly
|
||||
# from a different origin (e.g. another machine, dev tools, a reverse proxy
|
||||
# under a different hostname).
|
||||
#
|
||||
# Default "*" is permissive, fine for a single-user homelab. Lock it down in
|
||||
# real deployments:
|
||||
# ALLOWED_ORIGINS=https://photos.example.com
|
||||
# ALLOWED_ORIGINS=https://photos.example.com,http://192.168.1.10:3000
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
|
||||
# ── LOGGING / TIMEZONE ───────────────────────────────────────────────────────
|
||||
|
||||
# Python log level for the backend and Celery worker. Bump to DEBUG when
|
||||
# chasing scan / thumbnail issues.
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Container timezone. Affects the timestamps in logs and the "added at"
|
||||
# field on newly imported photos. Defaults to UTC.
|
||||
# TZ=Europe/Berlin
|
||||
# TZ=America/New_York
|
||||
TZ=UTC
|
||||
|
||||
|
||||
# ── WORKER CONCURRENCY ───────────────────────────────────────────────────────
|
||||
#
|
||||
# The ingestion pipeline runs on two Celery worker services with separate
|
||||
# concurrency knobs so heavy vision tasks can't starve cheap IO tasks:
|
||||
#
|
||||
# worker-light (default / high / low queues)
|
||||
# Runs: scan, thumbnails, EXIF, pHash, duplicate regrouping.
|
||||
# Mostly IO-bound — 2 prefork children keep a library streaming in.
|
||||
#
|
||||
# worker-vision (vision queue)
|
||||
# Runs: embeddings, object detection, OCR, face extraction, content
|
||||
# classification. Each prefork child loads ~2 GB of ONNX model weights,
|
||||
# so set this to roughly (physical_cores − 1) and watch RAM.
|
||||
#
|
||||
# Defaults target a ~6 core / 16 GB host. Raise these, then
|
||||
# docker compose up -d worker-light worker-vision
|
||||
# to pick them up. Lower for a Pi; go higher on a workstation.
|
||||
#
|
||||
# The old `CELERYD_CONCURRENCY=N` single-worker variable is no longer
|
||||
# read — delete it from your .env if it's set.
|
||||
CELERY_LIGHT_CONCURRENCY=2
|
||||
CELERY_VISION_CONCURRENCY=5
|
||||
|
||||
|
||||
# ── INTERNAL (rarely overridden) ─────────────────────────────────────────────
|
||||
|
||||
# These point at the in-compose Redis and the bind-mounted SQLite db. Override
|
||||
# only if you're running Mulita without docker-compose or against an external
|
||||
# Redis.
|
||||
# REDIS_URL=redis://redis:6379
|
||||
# CELERY_BROKER_URL=redis://redis:6379
|
||||
# CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
# DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
PP_LOG_LEVEL=info
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# PhotoPrism stack — example environment file.
|
||||
#
|
||||
# Copy to `.env.photoprism` and adjust. The compose file is invoked with
|
||||
# `--env-file .env.photoprism` so this never collides with the legacy
|
||||
# `.env` used by the mule-image stack.
|
||||
#
|
||||
# docker compose --env-file .env.photoprism -f docker-compose.photoprism.yml up -d
|
||||
|
||||
|
||||
# ── REQUIRED ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host path to your photo library. PhotoPrism reads this in place and
|
||||
# (post-M2) writes EXIF backwrites next to originals. Same path the legacy
|
||||
# mule-image backend used.
|
||||
PHOTO_DIRS=/mnt/library/homecloud/admin/files/
|
||||
|
||||
# Bootstrap admin password. The first PhotoPrism boot creates an `admin`
|
||||
# account with this password. Rotate after first login from the UI.
|
||||
PP_ADMIN_PASSWORD=please-change-me
|
||||
|
||||
# MariaDB passwords. Generate with `openssl rand -hex 24`.
|
||||
PP_DB_PASSWORD=please-change-me
|
||||
PP_DB_ROOT_PASSWORD=please-change-me
|
||||
|
||||
|
||||
# ── OPTIONAL ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host port for direct PhotoPrism UI access during M0–M3. Moves behind a
|
||||
# Caddy reverse proxy at M4; keep this open through then for debugging.
|
||||
PP_PORT=2342
|
||||
|
||||
# Site URL — used for share links, OIDC redirect URI, and reverse-proxy aware
|
||||
# URL generation. Set to the public hostname once the proxy is in front.
|
||||
PP_SITE_URL=http://localhost:2342/
|
||||
|
||||
# Auth mode — "password" for username/password (default), "public" for an
|
||||
# unauthenticated kiosk mode (don't use this on a multi-user library).
|
||||
PP_AUTH_MODE=password
|
||||
|
||||
# Library mount mode. M0 = "ro" (safe initial validation). Flip to "rw" at M2
|
||||
# when the right-sidebar enables EXIF backwrite. Set in lockstep with
|
||||
# PP_READONLY below.
|
||||
PP_ORIGINALS_MODE=ro
|
||||
PP_READONLY=true
|
||||
|
||||
# UID/GID inside the PhotoPrism container. Set these to the host UID/GID that
|
||||
# owns ${PHOTO_DIRS}. `id -u` and `id -g`.
|
||||
PP_UID=1000
|
||||
PP_GID=1000
|
||||
|
||||
|
||||
# ── OIDC SSO (Authentik or equivalent) ───────────────────────────────────────
|
||||
# Leave blank to keep OIDC dormant. Fill in to enable the "Sign in with OIDC"
|
||||
# button on the login page; OIDC_REGISTER=true auto-creates accounts at role
|
||||
# `user` (override to `admin` to grant full access on first SSO login).
|
||||
#
|
||||
# The compose file reads these and maps them to PhotoPrism's actual env-var
|
||||
# names (PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER) — see the
|
||||
# comment in docker-compose.photoprism.yml. The PhotoPrism callback URI is
|
||||
# auto-derived from PP_SITE_URL; do not set it manually.
|
||||
|
||||
# OIDC_PROVIDER_NAME=Authentik
|
||||
# OIDC_ISSUER_URL=https://auth.example.com/application/o/photoprism/
|
||||
# OIDC_CLIENT_ID=...
|
||||
# OIDC_CLIENT_SECRET=...
|
||||
# OIDC_SCOPES=openid profile email
|
||||
# OIDC_REGISTER=true
|
||||
# OIDC_ROLE=user
|
||||
|
||||
|
||||
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||
|
||||
PP_LOG_LEVEL=info
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,7 +37,6 @@ dist-ssr/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.photoprism
|
||||
|
||||
# Database
|
||||
*.db
|
||||
@@ -66,9 +65,6 @@ docker-compose.override.yml
|
||||
/pp/storage/
|
||||
/pp/import/
|
||||
|
||||
# Local-dev sample photo library used for M0 PhotoPrism validation.
|
||||
/photos-sample/
|
||||
|
||||
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
|
||||
/sidecar/data/
|
||||
|
||||
@@ -80,4 +76,3 @@ docker-compose.override.yml
|
||||
|
||||
# Thumbnails
|
||||
/thumbs/
|
||||
/trash/backend/yolov8n.pt
|
||||
|
||||
315
README.md
315
README.md
@@ -1,230 +1,125 @@
|
||||
# Mulita - Self-Hosted Photo Management Application
|
||||
# mule-image
|
||||
|
||||
A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow. Mulita provides a fast, keyboard-driven interface to browse, organize, tag, and manage your photo library.
|
||||
|
||||
## Features
|
||||
|
||||
- **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance
|
||||
- **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW
|
||||
- **Metadata Extraction**: Full EXIF/XMP metadata extraction and GPS mapping
|
||||
- **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions
|
||||
- **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
|
||||
- **Heaps**: Temporary collections for organizing photos
|
||||
- **Tags & Ratings**: Organize with tags, star ratings, and color labels — each with a card-grid browse view that drills into a full Timeline detail
|
||||
- **Dark Mode**: Photography-optimized dark interface
|
||||
- **Vision Pipeline**: YOLO object detection, OCR text extraction, CLIP embeddings for semantic search, InsightFace face detection and clustering
|
||||
- **People View**: Browse identified people as cards, click to see all photos of a person
|
||||
- **Map View**: Browse GPS-tagged photos on an interactive Leaflet map
|
||||
- **Duplicate Detection**: Perceptual hash-based duplicate grouping with best-pick UI
|
||||
- **Semantic Search**: Natural-language photo search powered by CLIP embeddings
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Python 3.12 with FastAPI
|
||||
- PostgreSQL + pgvector with SQLAlchemy (async) and Alembic migrations
|
||||
- Celery + Redis for background tasks
|
||||
- pyvips for fast thumbnail generation
|
||||
- ExifTool for metadata extraction
|
||||
- ONNX Runtime for vision models (YOLO, CLIP, InsightFace)
|
||||
|
||||
### Frontend
|
||||
- React 18 with TypeScript
|
||||
- Vite for fast development
|
||||
- TanStack Query for data fetching
|
||||
- TanStack Virtual for virtualized scrolling
|
||||
- Tailwind CSS for styling
|
||||
- Zustand for state management
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
|
||||
### Setup (one variable)
|
||||
|
||||
1. Clone the repo:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd muleimage
|
||||
```
|
||||
|
||||
2. Copy the example env file and set **one** variable — the **host**
|
||||
directory that contains your photo library. Whatever you point at
|
||||
will become your library inside Mulita.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# then edit .env and set PHOTO_DIRS:
|
||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
```
|
||||
|
||||
3. Start the stack:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. Open `http://localhost:3000`. On first boot Mulita will:
|
||||
- Mount your `PHOTO_DIRS` at `/photos` inside the container
|
||||
- Auto-create a source root called **Library** pointing at `/photos`
|
||||
- Queue an initial scan, generate thumbnails, and start serving them
|
||||
|
||||
You don't need to touch `mulita.yml` or the API to get started.
|
||||
|
||||
### Configuration knobs
|
||||
|
||||
Everything is environment-driven. `PHOTO_DIRS` is the only required
|
||||
value; the rest have sensible defaults documented in `.env.example`:
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|----------------------|---------|----------------------------------------------------|
|
||||
| `PHOTO_DIRS` | — | **Required.** Host path mounted at `/photos`. |
|
||||
| `FRONTEND_PORT` | `3000` | SPA host port. Bump if `3000` is taken. |
|
||||
| `BACKEND_PORT` | `8001` | Direct backend port (debug only — frontend uses internal nginx proxy). |
|
||||
| `REDIS_PORT` | `6379` | Redis host port (internal services don't need it). |
|
||||
| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. `https://photos.example.com`. |
|
||||
| `LOG_LEVEL` | `INFO` | Backend + worker log level. `DEBUG` for chasing scan issues. |
|
||||
| `TZ` | `UTC` | Container timezone. Affects log timestamps and "added at". |
|
||||
| `CELERYD_CONCURRENCY`| `4` | Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. |
|
||||
|
||||
### Accessing from another machine
|
||||
|
||||
The frontend talks to the backend through its bundled nginx, which
|
||||
proxies `/api/` to the backend on the internal compose network. That
|
||||
means requests are always **same-origin** as the page, so accessing
|
||||
Mulita from another host works without any CORS dance:
|
||||
|
||||
```
|
||||
http://<your-server-ip>:3000
|
||||
```
|
||||
|
||||
If you want to put it behind a reverse proxy at e.g.
|
||||
`https://photos.your.tld`, set `ALLOWED_ORIGINS` to that host so the
|
||||
backend's direct port (`BACKEND_PORT`) also accepts cross-origin
|
||||
requests if anything bypasses the proxy.
|
||||
|
||||
### How libraries are managed
|
||||
|
||||
Mulita is **config-driven**: the host directory you mount via
|
||||
`PHOTO_DIRS` becomes your library, and the backend automatically
|
||||
registers it as a source root on startup. There is no UI for adding
|
||||
or removing source roots — to change what Mulita scans, edit `.env`
|
||||
(or `docker-compose.yml` for multi-mount setups) and restart the
|
||||
stack.
|
||||
|
||||
This keeps the model simple: **the docker mount IS the library**.
|
||||
No two layers, no confusion about which view to use.
|
||||
|
||||
### Changing or adding libraries
|
||||
|
||||
To point at a different library:
|
||||
1. Edit `PHOTO_DIRS` in `.env`
|
||||
2. `docker compose down`
|
||||
3. (Optional, for a clean slate) `docker volume rm muleimage_db_data muleimage_thumbs_data muleimage_proxies_data`
|
||||
4. `docker compose up -d`
|
||||
|
||||
The new library shows up automatically. Without step 3 the old
|
||||
library's metadata stays in the DB and you'll see a warning at
|
||||
startup that the old source root's path is missing on disk —
|
||||
that's a hint to clean up.
|
||||
|
||||
For multiple libraries, edit `docker-compose.yml` and add additional
|
||||
mount lines:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ${PHOTO_DIRS}:/photos:rw
|
||||
- /Volumes/Archive:/archive:rw # additional library
|
||||
```
|
||||
|
||||
Each mounted directory will need a corresponding source root row in
|
||||
the DB; today that means `POST /api/v1/folders` via curl, or wait
|
||||
for the multi-mount auto-registration that's on the roadmap.
|
||||
|
||||
### Read-only libraries
|
||||
|
||||
The default mount is `:rw` because file operations (rename, move,
|
||||
empty discard pile) need to mutate the filesystem. If you want a
|
||||
strict read-only library — pointing at a network share, an
|
||||
authoritative archive, etc. — flip `:rw` to `:ro` in
|
||||
`docker-compose.yml`. Mulita will keep working for browsing, rating,
|
||||
color labels, picks, heaps, and the (soft) discard flag, but the
|
||||
following will return an OS error:
|
||||
|
||||
- `PATCH /photos/{id}` with a new `filename` (rename)
|
||||
- `POST /photos/move` (bulk move)
|
||||
- `DELETE /discard/empty` (file unlinks)
|
||||
|
||||
**Heads up**: with `:rw`, Mulita has full write access to whatever
|
||||
host directory you mount. Treat the same way you would Lightroom's
|
||||
catalog folder.
|
||||
Self-hosted photo management built on top of [PhotoPrism][pp]. A SvelteKit
|
||||
frontend ([`web/`](web/)) plus a small Go service ([`sidecar/`](sidecar/))
|
||||
fill in the keyboard-driven UI and the file/folder/mark endpoints
|
||||
PhotoPrism's REST API does not expose. PhotoPrism itself handles
|
||||
indexing, originals, thumbnails, and the database; we never re-implement
|
||||
those.
|
||||
|
||||
## Architecture
|
||||
|
||||
The application consists of 5 Docker services:
|
||||
|
||||
- **frontend**: React SPA served by Nginx
|
||||
- **backend**: FastAPI REST API
|
||||
- **worker**: Celery workers for background tasks (thumbnails, metadata, vision pipeline)
|
||||
- **redis**: Message broker for Celery
|
||||
- **db**: PostgreSQL with pgvector extension (for CLIP/face embeddings)
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` `→` `↑` `↓` | Navigate photos |
|
||||
| `Space` | Quick preview |
|
||||
| `Enter` | Open loupe view |
|
||||
| `T` | Add to active heap |
|
||||
| `1-5` | Set star rating |
|
||||
| `Tab` | Toggle left sidebar |
|
||||
| `I` | Toggle metadata panel |
|
||||
| `G` | Grid view |
|
||||
| `E` | Loupe view |
|
||||
| `Delete` | Move to trash |
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```text
|
||||
┌──────────────────┐ /api/v1/* ┌──────────────┐
|
||||
│ SvelteKit web/ │ ───────────────▶ │ photoprism │ ──▶ mariadb
|
||||
│ (Vite : 5173) │ /api/sidecar/* │ :2342 │
|
||||
│ │ ─────────┐ └──────────────┘
|
||||
└──────────────────┘ ▼
|
||||
┌──────────────┐
|
||||
│ sidecar │ ──▶ mariadb (mule_sidecar.*)
|
||||
│ :8000 │ ──▶ originals FS (rename / folders / dups)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
Three compose services — `mariadb`, `photoprism`, `sidecar` — plus the
|
||||
SvelteKit `web/` app served separately. PhotoPrism's port `2342` is
|
||||
**bound to `127.0.0.1` only**; it isn't a user-facing surface. The
|
||||
SvelteKit app is.
|
||||
|
||||
What the sidecar adds on top of PhotoPrism (full list in
|
||||
[`sidecar/README.md`](sidecar/README.md)):
|
||||
|
||||
- Per-photo marks (rating + color) persisted to `mule_sidecar.marks`
|
||||
- File rename + folder create/rename/delete with PhotoPrism reindex
|
||||
- Heap (album) → folder conversion
|
||||
- Perceptual-hash duplicate scan + archive
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
# edit .env: set PHOTO_DIRS to the host path holding your library
|
||||
# rotate PP_ADMIN_PASSWORD, PP_DB_PASSWORD, PP_DB_ROOT_PASSWORD
|
||||
# before any non-local deployment.
|
||||
|
||||
podman-compose --env-file .env \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.podman.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
Then serve the frontend. For local use the simplest path is the Vite
|
||||
dev server:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
# open http://localhost:5173
|
||||
```
|
||||
|
||||
For a static deployment, `npm run build` produces a bundle under
|
||||
`web/build/` that any static file host (nginx, Caddy, GitHub Pages-style)
|
||||
can serve. Reverse-proxy `/api/v1/*` to `http://127.0.0.1:2342` and
|
||||
`/api/sidecar/*` to `http://127.0.0.1:8000`.
|
||||
|
||||
PhotoPrism's own UI is still reachable from the host at
|
||||
`http://127.0.0.1:2342` if you need admin features (user management,
|
||||
settings) — set up an SSH tunnel from your laptop if the server is
|
||||
remote.
|
||||
|
||||
## Configuration
|
||||
|
||||
Source roots are managed by the UI / API (the database owns them). Edit
|
||||
`mulita.yml` to configure operational settings only:
|
||||
All knobs live in [`.env.example`](.env.example). The required ones:
|
||||
|
||||
- Thumbnail sizes, quality, and format
|
||||
- Scanner behaviour (watch, batch size, initial scan)
|
||||
- Performance tuning (concurrency, cache TTLs, DB pool)
|
||||
| Variable | Notes |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------|
|
||||
| `PHOTO_DIRS` | Host path mounted at `/photoprism/originals`. The library. |
|
||||
| `PP_ADMIN_PASSWORD` | First-boot admin password. Rotate. |
|
||||
| `PP_DB_PASSWORD` | MariaDB password for the `photoprism` user. Rotate. |
|
||||
| `PP_DB_ROOT_PASSWORD`| MariaDB root password. Rotate. |
|
||||
| `PP_UID` / `PP_GID` | Host UID/GID that owns `PHOTO_DIRS`. PhotoPrism + sidecar drop to this user inside. |
|
||||
| `PP_PORT` | Loopback host port for PhotoPrism (default `2342`). |
|
||||
| `PP_ORIGINALS_MODE` | `rw` (default) or `ro` — see [Read-only libraries](#read-only-libraries). |
|
||||
| `SIDECAR_PORT` | Loopback host port for the sidecar (default `8000`). |
|
||||
|
||||
## Performance
|
||||
Sidecar-specific env (DB DSN, `USER_BASEPATHS`, etc.) is documented in
|
||||
[`sidecar/README.md`](sidecar/README.md).
|
||||
|
||||
- Handles 100,000+ photos efficiently
|
||||
- Virtual scrolling for smooth timeline navigation
|
||||
- Thumbnail generation at 10+ photos/second
|
||||
- PostgreSQL full-text search with tsvector indexing
|
||||
- pgvector for fast nearest-neighbor embedding search
|
||||
## Read-only libraries
|
||||
|
||||
## Future Features
|
||||
The default originals mount is `:rw` because file operations (rename,
|
||||
folder mutations, duplicate archive, heap convert) need to mutate the
|
||||
filesystem. To run against a read-only archive, set
|
||||
`PP_ORIGINALS_MODE=ro` in `.env`. Browsing, marks, ratings, and color
|
||||
labels still work; the following sidecar endpoints return an OS error:
|
||||
|
||||
- Smart albums (auto-populated by saved filters)
|
||||
- Export presets
|
||||
- Multi-user support
|
||||
- `POST /api/sidecar/files/:uid/rename`
|
||||
- `POST /api/sidecar/folders` / `:rel/rename` / `DELETE /:rel`
|
||||
- `POST /api/sidecar/albums/:uid/convert`
|
||||
- `POST /api/sidecar/duplicates/archive`
|
||||
|
||||
## License
|
||||
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
|
||||
`PP_READONLY` and gates its own backwrite / import paths.
|
||||
|
||||
MIT
|
||||
## Dev iteration loop
|
||||
|
||||
For fast iteration on the sidecar without rebuilding its image on every
|
||||
change, run it as a host process — bring up just `mariadb` and
|
||||
`photoprism` from compose, then build and run the Go binary locally.
|
||||
Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-host-build).
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
.
|
||||
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
|
||||
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
|
||||
├── .env.example required env vars (copy to .env)
|
||||
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
|
||||
├── pp/ PhotoPrism bind-mounted state (storage, import)
|
||||
├── sidecar/ Go service — see sidecar/README.md
|
||||
└── web/ SvelteKit frontend
|
||||
```
|
||||
|
||||
[pp]: https://photoprism.app/
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Build dependencies
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
# Image processing libraries
|
||||
libvips42 \
|
||||
libvips-dev \
|
||||
# ExifTool for metadata extraction
|
||||
libimage-exiftool-perl \
|
||||
# FFmpeg for video processing
|
||||
ffmpeg \
|
||||
# Git for some Python packages
|
||||
git \
|
||||
# PostgreSQL client (for potential future use)
|
||||
postgresql-client \
|
||||
# Clean up
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
# buildkit cache mount keeps pip's download cache on disk across builds
|
||||
# so even when this layer is invalidated, wheels are reused locally.
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /data/thumbs /data/db /data/proxies /data/models /app/config
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -1,48 +0,0 @@
|
||||
# Alembic configuration for PhotoVault.
|
||||
#
|
||||
# The actual database URL is loaded at runtime by alembic/env.py from the
|
||||
# DATABASE_URL environment variable (with the async driver suffix stripped).
|
||||
# The placeholder below is only used for `alembic revision --autogenerate`
|
||||
# when no env var is set.
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+psycopg2://mulita:mulita@localhost:5432/mulita
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
Alembic environment for PhotoVault.
|
||||
|
||||
Pulls DATABASE_URL from the environment so the same migrations work in
|
||||
docker compose and locally. Strips the async driver suffix because Alembic
|
||||
runs synchronously via psycopg2.
|
||||
|
||||
Future-migration note
|
||||
---------------------
|
||||
Fresh installs run `Base.metadata.create_all` in `app.database.init_db`
|
||||
*before* migrations would normally apply, so any migration that adds a
|
||||
column / index / table to an object the model already declares will see
|
||||
that object already present. Write migrations defensively:
|
||||
|
||||
op.execute("ALTER TABLE photos ADD COLUMN IF NOT EXISTS new_col TEXT")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_foo ON foo(bar)")
|
||||
|
||||
For brand-new tables that the model also declares, the same applies — use
|
||||
`op.execute("CREATE TABLE IF NOT EXISTS ...")` or check first.
|
||||
"""
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
# Make `app` importable from this script.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.database import Base # noqa: E402
|
||||
# Import all models so they're registered on Base.metadata for autogenerate.
|
||||
from app.models import ( # noqa: E402, F401
|
||||
Photo,
|
||||
Folder,
|
||||
SourceRoot,
|
||||
Tag,
|
||||
Heap,
|
||||
HeapPhoto,
|
||||
)
|
||||
|
||||
config = context.config
|
||||
|
||||
# Resolve DATABASE_URL from env. Strip async driver suffixes — Alembic
|
||||
# uses sync drivers.
|
||||
db_url = os.environ.get("DATABASE_URL") or config.get_main_option("sqlalchemy.url")
|
||||
if db_url:
|
||||
if "+asyncpg" in db_url:
|
||||
db_url = db_url.replace("+asyncpg", "+psycopg2")
|
||||
elif db_url.startswith("postgresql://"):
|
||||
db_url = db_url.replace("postgresql://", "postgresql+psycopg2://", 1)
|
||||
elif "+aiosqlite" in db_url:
|
||||
db_url = db_url.replace("+aiosqlite", "")
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode (emit SQL only)."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations against a live database."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -1,27 +0,0 @@
|
||||
"""baseline (empty)
|
||||
|
||||
Revision ID: 0001_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-10
|
||||
|
||||
The current schema is created by SQLAlchemy `Base.metadata.create_all` in
|
||||
`app.database.init_db()` on first boot. Alembic only owns deltas from
|
||||
PR3 onward. This baseline is intentionally empty so `alembic upgrade head`
|
||||
on a fresh DB simply creates the `alembic_version` table and stamps it.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,85 +0,0 @@
|
||||
"""extend tags for vision pipeline
|
||||
|
||||
Revision ID: 0002_extend_tags
|
||||
Revises: 0001_baseline
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Add kind, source, representative_photo_id to tags table.
|
||||
Add confidence, bbox, source to photo_tags association.
|
||||
Switch uniqueness from (name) to (name, kind).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = "0002_extend_tags"
|
||||
down_revision: Union[str, None] = "0001_baseline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── tags table ────────────────────────────────────────────────────
|
||||
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS kind VARCHAR NOT NULL DEFAULT 'user'")
|
||||
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS source VARCHAR")
|
||||
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS representative_photo_id VARCHAR REFERENCES photos(id) ON DELETE SET NULL")
|
||||
|
||||
# Create index on kind for filtering
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_tags_kind ON tags(kind)")
|
||||
|
||||
# Drop old unique constraint on name (if it exists) and add (name, kind).
|
||||
# SQLAlchemy create_all may have created either — handle both cases.
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Drop the old single-column unique index/constraint if present.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'tags' AND indexname = 'ix_tags_name'
|
||||
) THEN
|
||||
DROP INDEX ix_tags_name;
|
||||
END IF;
|
||||
|
||||
-- Some SQLAlchemy versions create a unique constraint directly.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = 'tags' AND constraint_name = 'tags_name_key'
|
||||
) THEN
|
||||
ALTER TABLE tags DROP CONSTRAINT tags_name_key;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'uq_tags_name_kind'
|
||||
) THEN
|
||||
ALTER TABLE tags ADD CONSTRAINT uq_tags_name_kind UNIQUE (name, kind);
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# ── photo_tags table ──────────────────────────────────────────────
|
||||
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS confidence FLOAT")
|
||||
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS bbox JSONB")
|
||||
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS source VARCHAR")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# photo_tags columns
|
||||
op.drop_column("photo_tags", "source")
|
||||
op.drop_column("photo_tags", "bbox")
|
||||
op.drop_column("photo_tags", "confidence")
|
||||
|
||||
# tags: restore old unique constraint
|
||||
op.execute("ALTER TABLE tags DROP CONSTRAINT IF EXISTS uq_tags_name_kind")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tags_name ON tags(name)")
|
||||
|
||||
# tags columns
|
||||
op.drop_column("tags", "representative_photo_id")
|
||||
op.drop_column("tags", "source")
|
||||
op.drop_column("tags", "kind")
|
||||
@@ -1,52 +0,0 @@
|
||||
"""pgvector embeddings
|
||||
|
||||
Revision ID: 0003_pgvector_embeddings
|
||||
Revises: 0002_extend_tags
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Rewrite the embeddings table to use pgvector Vector(512) instead of
|
||||
LargeBinary. Add composite PK (photo_id, model), created_at, and
|
||||
HNSW index on vector column.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0003_pgvector_embeddings"
|
||||
down_revision: Union[str, None] = "0002_extend_tags"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the old placeholder table and recreate with pgvector types.
|
||||
# No data to preserve — it was never populated.
|
||||
op.execute("DROP TABLE IF EXISTS embeddings")
|
||||
op.execute("""
|
||||
CREATE TABLE embeddings (
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
model VARCHAR(64) NOT NULL,
|
||||
vector vector(512),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (photo_id, model)
|
||||
)
|
||||
""")
|
||||
# HNSW index for cosine similarity search.
|
||||
# Defer creation on large backfills — drop and recreate afterward.
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_embeddings_vector_hnsw
|
||||
ON embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS embeddings")
|
||||
op.execute("""
|
||||
CREATE TABLE embeddings (
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
model VARCHAR,
|
||||
vector BYTEA,
|
||||
PRIMARY KEY (photo_id)
|
||||
)
|
||||
""")
|
||||
@@ -1,82 +0,0 @@
|
||||
"""ocr_text table and Postgres FTS
|
||||
|
||||
Revision ID: 0004_ocr_fts
|
||||
Revises: 0003_pgvector_embeddings
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Create ocr_text table for storing OCR results. Add a tsvector column
|
||||
to photos for unified full-text search (filename + user_title +
|
||||
user_notes) with a GIN index. OCR text is rolled up into a materialized
|
||||
view or joined at query time.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_ocr_fts"
|
||||
down_revision: Union[str, None] = "0003_pgvector_embeddings"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── ocr_text table ────────────────────────────────────────────────
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ocr_text (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
language VARCHAR(8) DEFAULT '',
|
||||
confidence FLOAT,
|
||||
bbox JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_ocr_text_photo_id ON ocr_text(photo_id)")
|
||||
|
||||
# ── tsvector column on photos ─────────────────────────────────────
|
||||
op.execute("ALTER TABLE photos ADD COLUMN IF NOT EXISTS search_vector tsvector")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_photos_search_vector ON photos USING GIN (search_vector)")
|
||||
|
||||
# Trigger to auto-update search_vector on INSERT/UPDATE
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION photos_search_vector_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', coalesce(NEW.filename, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(NEW.user_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(NEW.user_notes, '')), 'B');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'photos_search_vector_trigger'
|
||||
) THEN
|
||||
CREATE TRIGGER photos_search_vector_trigger
|
||||
BEFORE INSERT OR UPDATE OF filename, user_title, user_notes
|
||||
ON photos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION photos_search_vector_update();
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Backfill existing rows
|
||||
op.execute("""
|
||||
UPDATE photos SET search_vector =
|
||||
setweight(to_tsvector('english', coalesce(filename, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(user_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(user_notes, '')), 'B')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS photos_search_vector_trigger ON photos")
|
||||
op.execute("DROP FUNCTION IF EXISTS photos_search_vector_update()")
|
||||
op.execute("DROP INDEX IF EXISTS ix_photos_search_vector")
|
||||
op.execute("ALTER TABLE photos DROP COLUMN IF EXISTS search_vector")
|
||||
op.execute("DROP TABLE IF EXISTS ocr_text")
|
||||
@@ -1,41 +0,0 @@
|
||||
"""face_embeddings table
|
||||
|
||||
Revision ID: 0005_face_embeddings
|
||||
Revises: 0004_ocr_fts
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Create face_embeddings table with pgvector Vector(128) for SFace
|
||||
recognition embeddings and HNSW index.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005_face_embeddings"
|
||||
down_revision: Union[str, None] = "0004_ocr_fts"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS face_embeddings (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
bbox JSONB,
|
||||
vector vector(128),
|
||||
cluster_id VARCHAR REFERENCES tags(id) ON DELETE SET NULL,
|
||||
quality FLOAT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_photo_id ON face_embeddings(photo_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_cluster_id ON face_embeddings(cluster_id)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
|
||||
ON face_embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS face_embeddings")
|
||||
@@ -1,39 +0,0 @@
|
||||
"""face_embeddings vector 128 -> 512
|
||||
|
||||
Revision ID: 0006_face_512d
|
||||
Revises: 0005_face_embeddings
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Resize face_embeddings.vector from Vector(128) to Vector(512) for
|
||||
ArcFace embeddings (InsightFace). Drops existing data and HNSW index,
|
||||
recreates both.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006_face_512d"
|
||||
down_revision: Union[str, None] = "0005_face_embeddings"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop index, truncate (old 128-d vectors are incompatible), resize
|
||||
op.execute("DROP INDEX IF EXISTS ix_face_embeddings_vector_hnsw")
|
||||
op.execute("DELETE FROM face_embeddings")
|
||||
op.execute("ALTER TABLE face_embeddings ALTER COLUMN vector TYPE vector(512)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
|
||||
ON face_embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_face_embeddings_vector_hnsw")
|
||||
op.execute("DELETE FROM face_embeddings")
|
||||
op.execute("ALTER TABLE face_embeddings ALTER COLUMN vector TYPE vector(128)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
|
||||
ON face_embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
@@ -1,67 +0,0 @@
|
||||
"""folders + photos is_hidden flag
|
||||
|
||||
Revision ID: 0007_folder_hidden
|
||||
Revises: 0006_face_512d
|
||||
Create Date: 2026-04-11
|
||||
|
||||
Adds an "exclude from cross-cutting views" flag:
|
||||
|
||||
folders.is_hidden — user-toggled on a folder or source root. When
|
||||
true, photos in that subtree are hidden from
|
||||
library-wide views (All Photos, Map, Tags,
|
||||
People, Search, Duplicates, sidebar counts) but
|
||||
remain indexed and visible when the user
|
||||
navigates into the folder directly.
|
||||
|
||||
photos.is_hidden — denormalized: true iff any ancestor folder in
|
||||
the photo's folder chain has is_hidden=true.
|
||||
Kept as a real column (rather than a recursive
|
||||
query per read) because the filter runs on
|
||||
essentially every photo query in the app, and
|
||||
the toggle operation that recomputes it is
|
||||
rare. Indexed so `WHERE NOT is_hidden` doesn't
|
||||
fall off the rating/taken_at indexes.
|
||||
|
||||
Both columns default to false so existing rows need no backfill.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0007_folder_hidden"
|
||||
down_revision: Union[str, None] = "0006_face_512d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"folders",
|
||||
sa.Column(
|
||||
"is_hidden",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"photos",
|
||||
sa.Column(
|
||||
"is_hidden",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_photos_is_hidden",
|
||||
"photos",
|
||||
["is_hidden"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_photos_is_hidden", table_name="photos")
|
||||
op.drop_column("photos", "is_hidden")
|
||||
op.drop_column("folders", "is_hidden")
|
||||
@@ -1,49 +0,0 @@
|
||||
"""photos has_date_warning flag
|
||||
|
||||
Revision ID: 0008_photos_date_warning
|
||||
Revises: 0007_folder_hidden
|
||||
Create Date: 2026-04-11
|
||||
|
||||
Adds `photos.has_date_warning` — a denormalized boolean that's true when
|
||||
the scanner's folder/filename date guesser disagrees with the stored
|
||||
taken_at by more than 24h (or taken_at is missing and the path would
|
||||
provide a date). Surfacing this as a real column means the filter bar
|
||||
can restrict the timeline to suspicious photos without the client
|
||||
recomputing the heuristic for every row.
|
||||
|
||||
Indexed because the filter is meant to run on top of the existing
|
||||
taken_at / folder queries that dominate the timeline, and we want the
|
||||
partial `WHERE has_date_warning` scan to stay cheap as the library
|
||||
grows.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0008_photos_date_warning"
|
||||
down_revision: Union[str, None] = "0007_folder_hidden"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"photos",
|
||||
sa.Column(
|
||||
"has_date_warning",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_photos_has_date_warning",
|
||||
"photos",
|
||||
["has_date_warning"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_photos_has_date_warning", table_name="photos")
|
||||
op.drop_column("photos", "has_date_warning")
|
||||
@@ -1,144 +0,0 @@
|
||||
"""users table and user_id foreign keys
|
||||
|
||||
Revision ID: 0009_users_and_auth
|
||||
Revises: 0008_photos_date_warning
|
||||
Create Date: 2026-04-12
|
||||
|
||||
Introduces multi-user support:
|
||||
1. Creates the `users` table.
|
||||
2. Adds `user_id` FK columns to photos, folders, source_roots, heaps, tags.
|
||||
3. For existing installs: creates a default admin user and assigns all
|
||||
existing rows to that user. The generated password is printed to the
|
||||
backend logs — the admin should change it on first login.
|
||||
4. Replaces the unique constraint on tags (name, kind) with
|
||||
(name, kind, user_id) so each user can have their own tags.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import uuid
|
||||
import secrets
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0009_users_and_auth"
|
||||
down_revision: Union[str, None] = "0008_photos_date_warning"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. Create users table (IF NOT EXISTS — safe on fresh installs where
|
||||
# init_db's create_all has already laid down the schema).
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR UNIQUE,
|
||||
hashed_password VARCHAR NOT NULL,
|
||||
role VARCHAR NOT NULL DEFAULT 'user',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
|
||||
media_path VARCHAR NOT NULL
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_username ON users (username)"
|
||||
))
|
||||
|
||||
# 2. Add user_id columns (nullable initially for the data migration)
|
||||
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
|
||||
conn.execute(sa.text(
|
||||
f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS user_id VARCHAR"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"
|
||||
))
|
||||
# FK — check if it already exists before adding
|
||||
fk_name = f"fk_{table}_user_id"
|
||||
fk_exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = :name AND table_name = :tbl"
|
||||
), {"name": fk_name, "tbl": table}).scalar()
|
||||
if not fk_exists:
|
||||
conn.execute(sa.text(
|
||||
f"ALTER TABLE {table} ADD CONSTRAINT {fk_name} "
|
||||
f"FOREIGN KEY (user_id) REFERENCES users(id)"
|
||||
))
|
||||
|
||||
# 3. Data migration: if rows exist, create a default admin and assign
|
||||
conn = op.get_bind()
|
||||
photo_count = conn.execute(sa.text("SELECT COUNT(*) FROM photos")).scalar()
|
||||
|
||||
if photo_count > 0:
|
||||
admin_id = str(uuid.uuid4())
|
||||
generated_password = secrets.token_urlsafe(16)
|
||||
|
||||
# Hash the password using passlib at migration time
|
||||
from passlib.context import CryptContext
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
hashed = pwd_context.hash(generated_password)
|
||||
|
||||
# Every user gets a subfolder — including the migrated admin.
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO users (id, username, hashed_password, role, media_path) "
|
||||
"VALUES (:id, :username, :hashed, :role, :media_path)"
|
||||
),
|
||||
{
|
||||
"id": admin_id,
|
||||
"username": "admin",
|
||||
"hashed": hashed,
|
||||
"role": "admin",
|
||||
"media_path": "/photos/admin",
|
||||
},
|
||||
)
|
||||
|
||||
# Assign all existing rows to the default admin
|
||||
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
|
||||
conn.execute(
|
||||
sa.text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
|
||||
{"uid": admin_id},
|
||||
)
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("alembic.migration")
|
||||
logger.warning(
|
||||
f"=== MIGRATION 0009 === Default admin created. "
|
||||
f"Username: admin | Password: {generated_password} | "
|
||||
f"Change this password on first login!"
|
||||
)
|
||||
|
||||
# 4. Replace tag unique constraint to include user_id
|
||||
# Check whether the old constraint exists before trying to drop it
|
||||
# (on fresh installs create_all creates the new constraint directly).
|
||||
old_uq_exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = 'uq_tags_name_kind' AND table_name = 'tags'"
|
||||
)).scalar()
|
||||
if old_uq_exists:
|
||||
op.drop_constraint("uq_tags_name_kind", "tags", type_="unique")
|
||||
|
||||
new_uq_exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = 'uq_tags_name_kind_user' AND table_name = 'tags'"
|
||||
)).scalar()
|
||||
if not new_uq_exists:
|
||||
op.create_unique_constraint("uq_tags_name_kind_user", "tags", ["name", "kind", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse the tag constraint
|
||||
op.drop_constraint("uq_tags_name_kind_user", "tags", type_="unique")
|
||||
op.create_unique_constraint("uq_tags_name_kind", "tags", ["name", "kind"])
|
||||
|
||||
# Drop user_id columns and FKs
|
||||
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
|
||||
op.drop_constraint(f"fk_{table}_user_id", table, type_="foreignkey")
|
||||
op.drop_index(f"ix_{table}_user_id", table_name=table)
|
||||
op.drop_column(table, "user_id")
|
||||
|
||||
# Drop users table
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -1,39 +0,0 @@
|
||||
"""embeddings vector 512 -> 768
|
||||
|
||||
Revision ID: 0010_embeddings_768d
|
||||
Revises: 0009_users_and_auth
|
||||
Create Date: 2026-04-12
|
||||
|
||||
Resize embeddings.vector from Vector(512) to Vector(768) for
|
||||
SigLIP2 ViT-B/16 embeddings. Drops existing data and HNSW index,
|
||||
recreates with the new dimension. Existing embeddings will be
|
||||
regenerated by the vision backfill task.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0010_embeddings_768d"
|
||||
down_revision: Union[str, None] = "0009_users_and_auth"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_embeddings_vector_hnsw")
|
||||
op.execute("DELETE FROM embeddings")
|
||||
op.execute("ALTER TABLE embeddings ALTER COLUMN vector TYPE vector(768)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_embeddings_vector_hnsw
|
||||
ON embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_embeddings_vector_hnsw")
|
||||
op.execute("DELETE FROM embeddings")
|
||||
op.execute("ALTER TABLE embeddings ALTER COLUMN vector TYPE vector(512)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_embeddings_vector_hnsw
|
||||
ON embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Add sharing tables for heaps and folders
|
||||
|
||||
Revision ID: 0011_sharing
|
||||
Revises: 0010_embeddings_768d
|
||||
Create Date: 2026-04-13
|
||||
|
||||
Adds heap_shares and folder_shares tables so users can share
|
||||
heaps and folders with other users (read or read+write).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0011_sharing"
|
||||
down_revision: Union[str, None] = "0010_embeddings_768d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS heap_shares (
|
||||
id VARCHAR NOT NULL PRIMARY KEY,
|
||||
heap_id VARCHAR NOT NULL REFERENCES heaps(id) ON DELETE CASCADE,
|
||||
owner_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
shared_with_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
permission VARCHAR NOT NULL DEFAULT 'read',
|
||||
created_at TIMESTAMP DEFAULT now(),
|
||||
CONSTRAINT uq_heap_share UNIQUE (heap_id, shared_with_id)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_heap_shares_shared_with ON heap_shares(shared_with_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_heap_shares_heap_id ON heap_shares(heap_id)")
|
||||
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS folder_shares (
|
||||
id VARCHAR NOT NULL PRIMARY KEY,
|
||||
folder_id VARCHAR NOT NULL,
|
||||
folder_type VARCHAR NOT NULL DEFAULT 'folder',
|
||||
owner_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
shared_with_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
permission VARCHAR NOT NULL DEFAULT 'read',
|
||||
created_at TIMESTAMP DEFAULT now(),
|
||||
CONSTRAINT uq_folder_share UNIQUE (folder_id, shared_with_id)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_folder_shares_shared_with ON folder_shares(shared_with_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_folder_shares_folder_id ON folder_shares(folder_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS folder_shares")
|
||||
op.execute("DROP TABLE IF EXISTS heap_shares")
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Strip AI pipeline to binary classifier only
|
||||
|
||||
Revision ID: 0012_strip_ai
|
||||
Revises: 0011_sharing
|
||||
Create Date: 2026-04-14
|
||||
|
||||
Removes face recognition, OCR, object detection, and semantic embeddings.
|
||||
The remaining AI is a single binary 'photography' vs 'other' classifier
|
||||
whose output feeds Tag(kind='content_type') and a new Photo.needs_review
|
||||
flag.
|
||||
|
||||
Drops: embeddings, face_embeddings, ocr_text tables.
|
||||
Drops: photo_tags rows produced by 'vision:yolov8n' and 'vision:sface'.
|
||||
Drops: tags with kind IN ('object','scene','face_cluster').
|
||||
Drops: tags.representative_photo_id column.
|
||||
Adds: photos.needs_review (bool, default false) + partial index.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_strip_ai"
|
||||
down_revision: Union[str, None] = "0011_sharing"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop dropped-AI tables. CASCADE clears any lingering FKs/indices.
|
||||
op.execute("DROP TABLE IF EXISTS embeddings CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS face_embeddings CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS ocr_text CASCADE")
|
||||
|
||||
# Clear ML-produced photo_tags rows and their parent tags.
|
||||
op.execute(
|
||||
"DELETE FROM photo_tags WHERE source IN ('vision:yolov8n','vision:sface')"
|
||||
)
|
||||
op.execute(
|
||||
"DELETE FROM tags WHERE kind IN ('object','scene','face_cluster')"
|
||||
)
|
||||
|
||||
# Drop the face-cluster representative column.
|
||||
op.execute("ALTER TABLE tags DROP COLUMN IF EXISTS representative_photo_id")
|
||||
|
||||
# Add the needs_review flag.
|
||||
op.execute(
|
||||
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS needs_review "
|
||||
"BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_photos_needs_review "
|
||||
"ON photos(needs_review) WHERE needs_review"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Data is not recoverable on downgrade — only the schema stubs are
|
||||
# put back so a future reinstall of the old pipeline can re-populate.
|
||||
op.execute("DROP INDEX IF EXISTS ix_photos_needs_review")
|
||||
op.execute("ALTER TABLE photos DROP COLUMN IF EXISTS needs_review")
|
||||
|
||||
op.execute(
|
||||
"ALTER TABLE tags ADD COLUMN IF NOT EXISTS representative_photo_id "
|
||||
"VARCHAR REFERENCES photos(id) ON DELETE SET NULL"
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Drop legacy content_type tags from the 6-category classifier
|
||||
|
||||
Revision ID: 0013_drop_old_ct
|
||||
Revises: 0012_strip_ai
|
||||
Create Date: 2026-04-14
|
||||
|
||||
The previous classifier wrote Tag(kind='content_type', name IN
|
||||
('photograph','screenshot','document','receipt','meme','artwork')).
|
||||
The new binary classifier writes names ('photography','other'). Both
|
||||
coexisted after the cutover so users saw duplicate groupings like
|
||||
'photography' alongside 'photograph'. Drop the old names — photo_tags
|
||||
rows cascade-delete via the FK.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0013_drop_old_ct"
|
||||
down_revision: Union[str, None] = "0012_strip_ai"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
LEGACY_NAMES = ('photograph', 'screenshot', 'document', 'receipt', 'meme', 'artwork')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"DELETE FROM tags WHERE kind = 'content_type' "
|
||||
f"AND name IN {LEGACY_NAMES}"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Add status + accepted_at to heap_shares and folder_shares
|
||||
|
||||
Revision ID: 0014_share_status
|
||||
Revises: 0013_drop_old_ct
|
||||
Create Date: 2026-04-21
|
||||
|
||||
Shares used to activate instantly on the owner's side. We now want a
|
||||
pending/accepted lifecycle so the recipient gets a notification bell and
|
||||
chooses to accept or decline before the shared item shows up in their
|
||||
sidebar.
|
||||
|
||||
Backfill note: every pre-existing row is treated as `accepted` with
|
||||
accepted_at = created_at. This is a pragmatic fiction — it keeps the
|
||||
sidebar populated after the migration without anyone having to click
|
||||
accept on shares that were already live. Any future "accepted X ago" UI
|
||||
inheriting this backfilled timestamp should be aware it's not a real
|
||||
user-action moment.
|
||||
|
||||
The one-migration trick: we add `status` with `server_default="accepted"`
|
||||
so the backfill happens in-place, then drop the default so new inserts
|
||||
fall through to the Python-side model default ("pending").
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_share_status"
|
||||
down_revision: Union[str, None] = "0013_drop_old_ct"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("heap_shares", "folder_shares"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="accepted",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
f"UPDATE {table} SET accepted_at = created_at "
|
||||
"WHERE accepted_at IS NULL"
|
||||
)
|
||||
# Drop the DB default so new rows inherit the Python-side
|
||||
# model default ("pending") instead of silently auto-accepting.
|
||||
op.alter_column(table, "status", server_default=None)
|
||||
op.create_index(
|
||||
f"ix_{table}_shared_with_status",
|
||||
table,
|
||||
["shared_with_id", "status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("heap_shares", "folder_shares"):
|
||||
op.drop_index(f"ix_{table}_shared_with_status", table_name=table)
|
||||
op.drop_column(table, "accepted_at")
|
||||
op.drop_column(table, "status")
|
||||
@@ -1,83 +0,0 @@
|
||||
"""OIDC identity + avatar / display_name on users
|
||||
|
||||
Revision ID: 0015_oidc_and_avatar
|
||||
Revises: 0014_share_status
|
||||
Create Date: 2026-04-22
|
||||
|
||||
Lets users sign in via an OIDC provider (Authentik) and carry a profile
|
||||
image / display name from the provider. Password-only users are
|
||||
unaffected.
|
||||
|
||||
1. Add users.oidc_issuer, users.oidc_sub (identity pair from the IdP).
|
||||
2. Add users.avatar_url, users.display_name (profile bits from claims
|
||||
or manually set).
|
||||
3. Make users.hashed_password nullable — OIDC-only users have no local
|
||||
password. Existing rows all have hashes so the NULLability change
|
||||
is backwards-compatible.
|
||||
4. Partial unique index on (oidc_issuer, oidc_sub) WHERE oidc_sub IS
|
||||
NOT NULL so multiple password-only users (both NULL) don't collide.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0015_oidc_and_avatar"
|
||||
down_revision: Union[str, None] = "0014_share_status"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1 + 2. Add new columns (idempotent — create_all on fresh installs
|
||||
# already built them from the model).
|
||||
for col_def in (
|
||||
"oidc_issuer VARCHAR",
|
||||
"oidc_sub VARCHAR",
|
||||
"avatar_url VARCHAR",
|
||||
"display_name VARCHAR",
|
||||
):
|
||||
conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}"))
|
||||
|
||||
# 3. Drop NOT NULL on hashed_password. Postgres only — SQLite can't
|
||||
# alter column nullability in place, but the SQLite escape hatch is
|
||||
# used for fresh local dev where create_all already wrote the new
|
||||
# nullable definition.
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL"
|
||||
))
|
||||
|
||||
# 4. Partial unique index — Postgres supports the WHERE clause so
|
||||
# NULLs don't collide; SQLite treats NULLs as distinct in unique
|
||||
# indexes already, so a plain unique index is safe there too.
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
|
||||
"ON users (oidc_issuer, oidc_sub) WHERE oidc_sub IS NOT NULL"
|
||||
))
|
||||
else:
|
||||
conn.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
|
||||
"ON users (oidc_issuer, oidc_sub)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_oidc_identity"))
|
||||
|
||||
if conn.dialect.name == "postgresql":
|
||||
# Can't re-apply NOT NULL if any OIDC-only user has NULL — so
|
||||
# only do it when safe.
|
||||
conn.execute(sa.text(
|
||||
"UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL"
|
||||
))
|
||||
|
||||
for col in ("display_name", "avatar_url", "oidc_sub", "oidc_issuer"):
|
||||
conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Nextcloud integration: per-user username override + encrypted app password
|
||||
|
||||
Revision ID: 0016_nextcloud_integration
|
||||
Revises: 0015_oidc_and_avatar
|
||||
Create Date: 2026-04-26
|
||||
|
||||
Lets each mule-image user wire their account to a Nextcloud account so
|
||||
photos can be browsed, indexed, and mutated under their own Nextcloud
|
||||
file tree (`/mnt/library/homecloud/<nc_user>/files/...` mounted into the
|
||||
backend + workers as `/nextcloud-users`). The OIDC `preferred_username`
|
||||
claim is the default mapping; the override field handles cases where the
|
||||
authentik username and the Nextcloud username don't match.
|
||||
|
||||
1. users.nextcloud_username — default sourced from preferred_username
|
||||
on OIDC login (only when null), editable via PATCH /api/v1/auth/me.
|
||||
2. users.nextcloud_app_password_enc — Fernet-encrypted Nextcloud app
|
||||
password used for HTTP Basic auth on WebDAV calls. Set from the
|
||||
Settings UI; the cleartext is never persisted.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0016_nextcloud_integration"
|
||||
down_revision: Union[str, None] = "0015_oidc_and_avatar"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
for col_def in (
|
||||
"nextcloud_username VARCHAR",
|
||||
"nextcloud_app_password_enc VARCHAR",
|
||||
):
|
||||
conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}"))
|
||||
|
||||
# Index the username for the per-user path-scoping check on /browse
|
||||
# and /source-roots — keeps lookups fast even on tiny user tables.
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_nextcloud_username "
|
||||
"ON users (nextcloud_username)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_nextcloud_username"))
|
||||
for col in ("nextcloud_app_password_enc", "nextcloud_username"):
|
||||
conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Partial index for the photos list query
|
||||
|
||||
Revision ID: 0017_photos_list_index
|
||||
Revises: 0016_nextcloud_integration
|
||||
Create Date: 2026-05-10
|
||||
|
||||
The default photos-list query (GET /api/v1/photos?per_page=N&sort=taken_at&order=desc)
|
||||
filters `NOT is_trashed AND NOT is_hidden` and sorts by
|
||||
`(taken_at DESC NULLS LAST, id DESC)`. EXPLAIN ANALYZE on a 21k-row
|
||||
table showed a seq-scan + top-N heapsort (~20ms standalone, worse under
|
||||
concurrency) — Postgres can't use the single-column ix_photos_taken_at
|
||||
when the leading WHERE clause is two booleans.
|
||||
|
||||
A partial index on the sort key, scoped to the visible subset, lets the
|
||||
planner index-scan in reverse and stop at LIMIT N. Two booleans select
|
||||
~95% of rows, so the partial predicate is tighter than the full table
|
||||
without losing common queries.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0017_photos_list_index"
|
||||
down_revision: Union[str, None] = "0016_nextcloud_integration"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_photos_list_visible
|
||||
ON photos (taken_at DESC NULLS LAST, id DESC)
|
||||
WHERE NOT is_trashed AND NOT is_hidden
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_photos_list_visible")
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Add photos.nextcloud_fileid for NC preview proxying
|
||||
|
||||
Revision ID: 0018_photos_nextcloud_fileid
|
||||
Revises: 0017_photos_list_index
|
||||
Create Date: 2026-05-11
|
||||
|
||||
The thumbnail endpoint will proxy Nextcloud's /index.php/core/preview
|
||||
instead of generating and serving its own WebP cache under /data/thumbs.
|
||||
That requires storing each photo's Nextcloud numeric fileid alongside
|
||||
the row. NULL is allowed because legacy / non-NC photos still exist
|
||||
and the handler keeps the on-disk fallback for them.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0018_photos_nextcloud_fileid"
|
||||
down_revision: Union[str, None] = "0017_photos_list_index"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"photos",
|
||||
sa.Column("nextcloud_fileid", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_photos_nextcloud_fileid",
|
||||
"photos",
|
||||
["nextcloud_fileid"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_photos_nextcloud_fileid", table_name="photos")
|
||||
op.drop_column("photos", "nextcloud_fileid")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Drop AI remnants: photos.needs_review and the pgvector extension
|
||||
|
||||
Revision ID: 0019_drop_ai_remnants
|
||||
Revises: 0018_photos_nextcloud_fileid
|
||||
Create Date: 2026-05-14
|
||||
|
||||
The vision pipeline has been removed entirely (no more classifier, no
|
||||
worker-vision service, no torch/onnxruntime/open-clip-torch deps). The
|
||||
`needs_review` boolean and its partial index were populated only by
|
||||
that classifier and have no remaining writers or readers.
|
||||
|
||||
The pgvector extension was originally added by 0003_pgvector_embeddings
|
||||
for the embeddings table that 0012_strip_ai_pipeline dropped; the
|
||||
extension itself is now unused, and the next deploy switches the
|
||||
Postgres image from pgvector/pgvector:pg16 to plain postgres:16. The
|
||||
extension must be dropped *before* that image swap or the new
|
||||
container will fail to load existing CREATE EXTENSION declarations.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0019_drop_ai_remnants"
|
||||
down_revision: Union[str, None] = "0018_photos_nextcloud_fileid"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_photos_needs_review")
|
||||
op.execute("ALTER TABLE photos DROP COLUMN IF EXISTS needs_review")
|
||||
op.execute("DROP EXTENSION IF EXISTS vector")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Re-create the column as a no-op (data is gone). The pgvector
|
||||
# extension is intentionally NOT re-added — matches the precedent
|
||||
# set by 0012_strip_ai_pipeline for its dropped tables.
|
||||
op.execute(
|
||||
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS needs_review "
|
||||
"BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_photos_needs_review "
|
||||
"ON photos(needs_review) WHERE needs_review"
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""
|
||||
Authentication utilities — password hashing and JWT token management.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(user_id: str, role: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and validate a JWT. Raises JWTError on any problem."""
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||
@@ -1,77 +0,0 @@
|
||||
"""OIDC (OpenID Connect) client setup — used for Authentik SSO today,
|
||||
generic enough to register other providers later.
|
||||
|
||||
Authlib handles the Authorization Code + PKCE flow, including discovery
|
||||
via the provider's `.well-known/openid-configuration` document. We keep
|
||||
a single registered client named "authentik" regardless of label, so the
|
||||
router code always knows where to find it.
|
||||
|
||||
If OIDC isn't fully configured the module stays inert — `is_enabled()`
|
||||
returns False and the registry has no client. Callers must guard.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
PROVIDER_NAME = "authentik"
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""True when every required OIDC setting is present."""
|
||||
return bool(
|
||||
settings.oidc_issuer
|
||||
and settings.oidc_client_id
|
||||
and settings.oidc_client_secret
|
||||
and settings.oidc_redirect_uri
|
||||
)
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""True when OIDC is both configured and the admin flipped it on."""
|
||||
return bool(settings.oidc_enabled) and is_configured()
|
||||
|
||||
|
||||
def provider_label() -> str:
|
||||
return settings.oidc_provider_label or "Authentik"
|
||||
|
||||
|
||||
def _build_oauth() -> OAuth:
|
||||
"""Build the Authlib OAuth registry. Always safe to call; only
|
||||
registers the provider when credentials are present so importing
|
||||
this module never fails on a fresh install.
|
||||
"""
|
||||
registry = OAuth()
|
||||
if not is_configured():
|
||||
return registry
|
||||
|
||||
# Authentik exposes discovery at `{issuer}/.well-known/openid-configuration`.
|
||||
# Trailing slash handling varies by Authentik version, so normalise.
|
||||
issuer = settings.oidc_issuer.rstrip("/")
|
||||
discovery_url = f"{issuer}/.well-known/openid-configuration"
|
||||
|
||||
registry.register(
|
||||
name=PROVIDER_NAME,
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": settings.oidc_scopes,
|
||||
# Force PKCE — cheap win for public clients, harmless for
|
||||
# confidential ones.
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
oauth: OAuth = _build_oauth()
|
||||
|
||||
|
||||
def get_client() -> Optional[object]:
|
||||
"""Return the registered provider client, or None when not configured."""
|
||||
if not is_configured():
|
||||
return None
|
||||
return oauth.create_client(PROVIDER_NAME)
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
Application configuration using Pydantic Settings
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
class ThumbnailSettings(BaseModel):
|
||||
"""Thumbnail generation settings"""
|
||||
small: int = 240
|
||||
medium: int = 640
|
||||
large: int = 1280
|
||||
quality: int = 85
|
||||
format: str = "webp"
|
||||
|
||||
class ScannerSettings(BaseModel):
|
||||
"""File scanner settings"""
|
||||
watch: bool = True
|
||||
initial_scan_on_start: bool = True
|
||||
batch_size: int = 100
|
||||
concurrent_workers: int = 4
|
||||
|
||||
class PerformanceSettings(BaseModel):
|
||||
"""Performance tuning settings"""
|
||||
max_concurrent_thumbnails: int = 10
|
||||
cache_ttl: int = 3600
|
||||
db_pool_size: int = 10
|
||||
db_pool_max_overflow: int = 10
|
||||
db_pool_recycle: int = 3600
|
||||
|
||||
class MulitaConfig(BaseModel):
|
||||
"""Main configuration from YAML file. Source roots and the discard
|
||||
workflow are owned by the database now — only operational settings
|
||||
live here."""
|
||||
thumbnails: ThumbnailSettings = ThumbnailSettings()
|
||||
scanner: ScannerSettings = ScannerSettings()
|
||||
performance: PerformanceSettings = PerformanceSettings()
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
# Database — plain Postgres. The SQLite escape hatch remains
|
||||
# supported via the docker-compose.sqlite.yml override and by setting
|
||||
# DATABASE_URL=sqlite+aiosqlite:///... in .env for local dev.
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://mulita:mulita@db:5432/mulita",
|
||||
env="DATABASE_URL"
|
||||
)
|
||||
|
||||
# Redis
|
||||
redis_url: str = Field(
|
||||
default="redis://localhost:6379",
|
||||
env="REDIS_URL"
|
||||
)
|
||||
|
||||
# Celery
|
||||
celery_broker_url: str = Field(
|
||||
default="redis://localhost:6379",
|
||||
env="CELERY_BROKER_URL"
|
||||
)
|
||||
celery_result_backend: str = Field(
|
||||
default="redis://localhost:6379",
|
||||
env="CELERY_RESULT_BACKEND"
|
||||
)
|
||||
|
||||
# Photo directories
|
||||
photo_dirs: str = Field(
|
||||
default="/photos",
|
||||
env="PHOTO_DIRS"
|
||||
)
|
||||
|
||||
# API settings
|
||||
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
||||
api_port: int = Field(default=8000, env="API_PORT")
|
||||
|
||||
# CORS — comma-separated list of allowed origins, or "*" for any.
|
||||
# Same-origin requests (the normal case behind nginx / vite proxy)
|
||||
# never trip CORS, so this is only for direct browser access from
|
||||
# other origins (LAN IP, reverse proxy, dev tools).
|
||||
allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS")
|
||||
|
||||
# Logging — accepts standard python levels (DEBUG, INFO, WARNING,
|
||||
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
|
||||
# Auth — JWT signing key. Set SECRET_KEY in .env for production.
|
||||
# If unset, a deterministic fallback is used (acceptable for
|
||||
# single-machine homelab deploys, but set a real key if the instance
|
||||
# is network-exposed).
|
||||
secret_key: str = Field(
|
||||
default="mulita-dev-secret-change-me",
|
||||
env="SECRET_KEY",
|
||||
)
|
||||
access_token_expire_minutes: int = Field(default=525600, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 1 year
|
||||
refresh_token_expire_days: int = Field(default=3650, env="REFRESH_TOKEN_EXPIRE_DAYS") # 10 years
|
||||
|
||||
# ── OIDC / Authentik single sign-on ────────────────────────────────
|
||||
# Disabled by default; enable by setting OIDC_ENABLED=true and the
|
||||
# issuer + client credentials. When enabled the login page shows a
|
||||
# "Sign in with {label}" button alongside the username/password form.
|
||||
oidc_enabled: bool = Field(default=False, env="OIDC_ENABLED")
|
||||
oidc_issuer: Optional[str] = Field(default=None, env="OIDC_ISSUER")
|
||||
oidc_client_id: Optional[str] = Field(default=None, env="OIDC_CLIENT_ID")
|
||||
oidc_client_secret: Optional[str] = Field(default=None, env="OIDC_CLIENT_SECRET")
|
||||
# Absolute URL the IdP redirects back to. Must match the Redirect URI
|
||||
# configured on the Authentik side exactly.
|
||||
oidc_redirect_uri: Optional[str] = Field(default=None, env="OIDC_REDIRECT_URI")
|
||||
oidc_scopes: str = Field(default="openid profile email", env="OIDC_SCOPES")
|
||||
oidc_provider_label: str = Field(default="Authentik", env="OIDC_PROVIDER_LABEL")
|
||||
# When true, a successful OIDC login for a subject we've never seen
|
||||
# auto-creates a local user + their /photos/{username} folder. When
|
||||
# false, unknown subjects get 403 and must be pre-provisioned.
|
||||
oidc_allow_signup: bool = Field(default=True, env="OIDC_ALLOW_SIGNUP")
|
||||
# Comma-separated Authentik group names. Any group-claim match
|
||||
# promotes the user to role=admin; otherwise role=user. Role is
|
||||
# refreshed on every sign-in so removals demote automatically.
|
||||
oidc_admin_groups: str = Field(default="", env="OIDC_ADMIN_GROUPS")
|
||||
# Last-resort link step: if (issuer, sub) AND email fallback both
|
||||
# miss, try matching the IdP's `preferred_username` claim against
|
||||
# `users.username`. Safe in single-tenant setups where the IdP is
|
||||
# the source of truth for usernames (homelab, family instance).
|
||||
# Leave off in multi-tenant — a name collision would hand someone
|
||||
# else's account to a new SSO user.
|
||||
oidc_link_by_username: bool = Field(default=False, env="OIDC_LINK_BY_USERNAME")
|
||||
# Starlette session cookie secret — only used to hold PKCE/state
|
||||
# during the brief OIDC round-trip. Falls back to secret_key when
|
||||
# unset.
|
||||
session_secret: Optional[str] = Field(default=None, env="SESSION_SECRET")
|
||||
|
||||
@property
|
||||
def oidc_admin_group_list(self) -> list[str]:
|
||||
raw = (self.oidc_admin_groups or "").strip()
|
||||
return [g.strip() for g in raw.split(",") if g.strip()]
|
||||
|
||||
@property
|
||||
def effective_session_secret(self) -> str:
|
||||
return self.session_secret or self.secret_key
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
||||
- "*" → wildcard (single-element list ["*"])
|
||||
- "http://a.com,http://b.com" → split + strip
|
||||
Empty entries are dropped.
|
||||
"""
|
||||
raw = (self.allowed_origins or "").strip()
|
||||
if not raw or raw == "*":
|
||||
return ["*"]
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
# App configuration from YAML
|
||||
_config: Optional[MulitaConfig] = None
|
||||
|
||||
@property
|
||||
def config(self) -> MulitaConfig:
|
||||
"""Load configuration from YAML file"""
|
||||
if self._config is None:
|
||||
config_path = Path("/app/config/mulita.yml")
|
||||
if not config_path.exists():
|
||||
config_path = Path("mulita.yml")
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path, "r") as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
self._config = MulitaConfig(**config_data)
|
||||
else:
|
||||
self._config = MulitaConfig()
|
||||
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def thumbnails(self) -> ThumbnailSettings:
|
||||
return self.config.thumbnails
|
||||
|
||||
@property
|
||||
def scanner(self) -> ScannerSettings:
|
||||
return self.config.scanner
|
||||
|
||||
@property
|
||||
def performance(self) -> PerformanceSettings:
|
||||
return self.config.performance
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
@@ -1,204 +0,0 @@
|
||||
"""
|
||||
Database configuration and session management.
|
||||
|
||||
Schema management strategy
|
||||
--------------------------
|
||||
Postgres (default): Alembic owns schema deltas. `alembic upgrade head` is
|
||||
run before the app starts (in the container CMD). `init_db()` calls
|
||||
`create_all` afterward as the source of truth for fresh installs — it is
|
||||
idempotent for existing tables and creates any tables defined on
|
||||
`Base.metadata` that don't yet exist. Future Alembic migrations should be
|
||||
written defensively (`IF NOT EXISTS` etc.) so they remain safe to run on a
|
||||
fresh DB where `create_all` has already laid down the same objects.
|
||||
|
||||
SQLite (escape hatch via docker-compose.sqlite.yml): no Alembic. The
|
||||
historical inline ALTER TABLE block stays in place so existing dev
|
||||
installs keep upgrading.
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_is_sqlite = settings.database_url.startswith("sqlite")
|
||||
_is_postgres = settings.database_url.startswith("postgresql")
|
||||
|
||||
# When running inside a Celery worker we use NullPool rather than the
|
||||
# default connection pool. The reasons stack up:
|
||||
#
|
||||
# 1. Celery's prefork model forks the master *after* imports, so every
|
||||
# child inherits the same asyncpg Connection objects — they share
|
||||
# a socket, and two children using one concurrently raises
|
||||
# "another operation is in progress".
|
||||
#
|
||||
# 2. Task bodies run under `asyncio.run()`, which spins up a fresh
|
||||
# event loop per invocation. A pooled asyncpg Connection created
|
||||
# on loop A, returned to the pool, and checked out on loop B
|
||||
# raises "Future attached to a different loop".
|
||||
#
|
||||
# NullPool dodges both: every session checkout opens a brand-new
|
||||
# connection on the *current* loop and the connection is closed at
|
||||
# session end. Connection setup is cheap compared to task cost, so this
|
||||
# is the right default for the worker. The FastAPI backend keeps the
|
||||
# normal pool because it serves many short requests on a single long-
|
||||
# lived event loop, where pooling is a clear win.
|
||||
_is_celery_worker = os.environ.get("MULITA_CELERY_WORKER") == "1"
|
||||
|
||||
if _is_sqlite:
|
||||
db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
connect_args={
|
||||
"check_same_thread": False,
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
elif _is_celery_worker:
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=settings.performance.db_pool_size,
|
||||
max_overflow=settings.performance.db_pool_max_overflow,
|
||||
pool_recycle=settings.performance.db_pool_recycle,
|
||||
pool_pre_ping=True,
|
||||
pool_timeout=10, # fail fast if pool exhausted (default 30)
|
||||
# Kill connections idle in a transaction for >60s. Prevents leaked
|
||||
# sessions from thumbnail requests that disconnect mid-flight.
|
||||
connect_args={"server_settings": {"idle_in_transaction_session_timeout": "60000"}},
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency to get database session.
|
||||
|
||||
Rolls back any uncommitted transaction before closing so a client
|
||||
disconnect doesn't leave idle-in-transaction connections in the pool.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database, create tables if they don't exist"""
|
||||
async with engine.begin() as conn:
|
||||
# Import all models to register them with Base
|
||||
from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto # noqa: F401
|
||||
|
||||
# Create all tables. Note: create_all only creates *missing* tables —
|
||||
# it does NOT add new columns to existing tables when the model gains
|
||||
# them. On Postgres, Alembic handles deltas; on SQLite, the inline
|
||||
# ALTER block below is the legacy fallback.
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
gps_columns_added = False
|
||||
|
||||
if _is_sqlite:
|
||||
# WAL mode for better concurrency.
|
||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
await conn.execute(text("PRAGMA synchronous=NORMAL"))
|
||||
await conn.execute(text("PRAGMA cache_size=10000"))
|
||||
await conn.execute(text("PRAGMA temp_store=MEMORY"))
|
||||
|
||||
# ── Idempotent column adds (SQLite only) ─────────────────────
|
||||
# SQLite supports ADD COLUMN but not "IF NOT EXISTS" for
|
||||
# columns, so introspect via PRAGMA first. Each entry is
|
||||
# (column_name, ALTER statement). Add new columns at the
|
||||
# bottom. On Postgres these live in Alembic migrations.
|
||||
existing_cols = {
|
||||
row[1]
|
||||
for row in (
|
||||
await conn.execute(text("PRAGMA table_info(photos)"))
|
||||
).fetchall()
|
||||
}
|
||||
pending_alters: list[tuple[str, str]] = [
|
||||
("phash", "ALTER TABLE photos ADD COLUMN phash VARCHAR(16)"),
|
||||
(
|
||||
"duplicate_group_id",
|
||||
"ALTER TABLE photos ADD COLUMN duplicate_group_id VARCHAR",
|
||||
),
|
||||
("latitude", "ALTER TABLE photos ADD COLUMN latitude REAL"),
|
||||
("longitude", "ALTER TABLE photos ADD COLUMN longitude REAL"),
|
||||
]
|
||||
for col_name, alter_sql in pending_alters:
|
||||
if col_name not in existing_cols:
|
||||
logger.info(f"Adding photos.{col_name} column")
|
||||
await conn.execute(text(alter_sql))
|
||||
if col_name in ("latitude", "longitude"):
|
||||
gps_columns_added = True
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_photos_phash ON photos(phash)")
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_photos_duplicate_group_id "
|
||||
"ON photos(duplicate_group_id)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_photos_lat_lon "
|
||||
"ON photos(latitude, longitude)"
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# If we just introduced the GPS columns on an existing SQLite
|
||||
# install, kick off a one-shot backfill so the Map view is
|
||||
# populated without a manual full re-scan. Postgres installs are
|
||||
# always fresh (no SQLite→PG migration path), so this code path
|
||||
# is SQLite-only.
|
||||
if _is_sqlite and gps_columns_added:
|
||||
try:
|
||||
from app.tasks.scan import backfill_gps
|
||||
backfill_gps.delay()
|
||||
logger.info("Queued one-shot backfill_gps task after column add")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not queue backfill_gps task: {e}")
|
||||
|
||||
async def create_fts_table():
|
||||
"""Create Full-Text Search table for SQLite. On Postgres this is
|
||||
replaced by a tsvector column on the photos table (added in PR5)."""
|
||||
if _is_sqlite:
|
||||
async with engine.begin() as conn:
|
||||
# Create FTS5 virtual table for full-text search
|
||||
await conn.execute(text("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
|
||||
photo_id UNINDEXED,
|
||||
filename,
|
||||
user_title,
|
||||
user_notes,
|
||||
exif_text,
|
||||
tokenize='unicode61'
|
||||
)
|
||||
"""))
|
||||
logger.info("FTS5 table created successfully")
|
||||
@@ -1,355 +0,0 @@
|
||||
"""
|
||||
FastAPI dependencies for authentication and user-scoped data access.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.auth import decode_token
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import Folder, SourceRoot
|
||||
from app.models.heaps import Heap, heap_photos
|
||||
from app.models.tags import Tag
|
||||
from app.models.sharing import HeapShare, FolderShare
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Decode JWT, look up user, raise 401 if invalid or inactive."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
if user_id is None or token_type != "access":
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_media(
|
||||
request: Request,
|
||||
token: Optional[str] = Query(None, alias="token"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Authenticate via Authorization header OR ?token= query parameter.
|
||||
|
||||
Used for media endpoints (thumbnails, originals, proxies) where the
|
||||
URL is set as an <img src> or <video src> and the browser can't
|
||||
attach an Authorization header. The frontend appends ?token=JWT to
|
||||
media URLs so they pass auth without custom fetch logic.
|
||||
"""
|
||||
# Try Authorization header first.
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
jwt_token = None
|
||||
if auth_header.startswith("Bearer "):
|
||||
jwt_token = auth_header[7:]
|
||||
elif token:
|
||||
jwt_token = token
|
||||
|
||||
if not jwt_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(jwt_token)
|
||||
user_id: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
if user_id is None or token_type != "access":
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Raise 403 if user is not an admin."""
|
||||
if user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-scoped query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def user_photos_query(user: User):
|
||||
"""Base select for photos owned by user, with tags eager-loaded."""
|
||||
return (
|
||||
select(Photo)
|
||||
.options(selectinload(Photo.tags))
|
||||
.where(Photo.user_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
async def get_user_photo(
|
||||
photo_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Photo:
|
||||
"""Fetch a single photo by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Photo)
|
||||
.options(selectinload(Photo.tags))
|
||||
.where(Photo.id == photo_id, Photo.user_id == user.id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
if photo is None:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
return photo
|
||||
|
||||
|
||||
async def get_user_folder(
|
||||
folder_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Folder:
|
||||
"""Fetch a single folder by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
return folder
|
||||
|
||||
|
||||
async def get_user_heap(
|
||||
heap_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Heap:
|
||||
"""Fetch a single heap by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Heap).where(Heap.id == heap_id, Heap.user_id == user.id)
|
||||
)
|
||||
heap = result.scalar_one_or_none()
|
||||
if heap is None:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
return heap
|
||||
|
||||
|
||||
async def get_user_tag(
|
||||
tag_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Tag:
|
||||
"""Fetch a single tag by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Tag).where(Tag.id == tag_id, Tag.user_id == user.id)
|
||||
)
|
||||
tag = result.scalar_one_or_none()
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return tag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sharing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_user_or_shared_heap(
|
||||
heap_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> tuple:
|
||||
"""Fetch a heap the user owns OR has a share for.
|
||||
|
||||
Returns ``(heap, permission)`` where *permission* is
|
||||
``'owner'``, ``'read'``, or ``'write'``. Raises 404 if no access.
|
||||
"""
|
||||
# Fast path: owned by current user.
|
||||
result = await db.execute(
|
||||
select(Heap).where(Heap.id == heap_id, Heap.user_id == user.id)
|
||||
)
|
||||
heap = result.scalar_one_or_none()
|
||||
if heap:
|
||||
return heap, "owner"
|
||||
|
||||
# Shared path.
|
||||
result = await db.execute(
|
||||
select(HeapShare).where(
|
||||
HeapShare.heap_id == heap_id,
|
||||
HeapShare.shared_with_id == user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share:
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if heap:
|
||||
return heap, share.permission
|
||||
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
|
||||
async def get_user_or_shared_folder(
|
||||
folder_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> tuple:
|
||||
"""Fetch a folder (or source root) the user owns OR has a share for.
|
||||
|
||||
Returns ``(entity, permission)`` where *entity* is a Folder or
|
||||
SourceRoot and *permission* is ``'owner'``, ``'read'``, or ``'write'``.
|
||||
"""
|
||||
# Try owned folder first.
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder:
|
||||
return folder, "owner"
|
||||
|
||||
# Try owned source root.
|
||||
result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == user.id)
|
||||
)
|
||||
sr = result.scalar_one_or_none()
|
||||
if sr:
|
||||
return sr, "owner"
|
||||
|
||||
# Shared path.
|
||||
result = await db.execute(
|
||||
select(FolderShare).where(
|
||||
FolderShare.folder_id == folder_id,
|
||||
FolderShare.shared_with_id == user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share:
|
||||
if share.folder_type == "source_root":
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
|
||||
else:
|
||||
result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
entity = result.scalar_one_or_none()
|
||||
if entity:
|
||||
return entity, share.permission
|
||||
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
|
||||
async def resolve_username(
|
||||
username: str,
|
||||
db: AsyncSession,
|
||||
) -> User:
|
||||
"""Look up an active user by username. Raises 404 if not found."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == username, User.is_active.is_(True))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def can_access_photo_via_share(
|
||||
photo_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""Check whether *user* can access *photo_id* through any share.
|
||||
|
||||
Returns True if the photo belongs to a heap or folder that has been
|
||||
shared with the user. Used as a fallback in media-serving endpoints
|
||||
after the direct ownership check fails.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check heap shares: photo in any heap shared with user?
|
||||
result = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(
|
||||
heap_photos.c.photo_id == photo_id,
|
||||
heap_photos.c.heap_id.in_(
|
||||
select(HeapShare.heap_id).where(HeapShare.shared_with_id == user.id)
|
||||
),
|
||||
).limit(1)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
return True
|
||||
|
||||
# Check folder shares: photo in any folder (or descendant) shared with user?
|
||||
result = await db.execute(
|
||||
select(Photo.folder_id).where(Photo.id == photo_id)
|
||||
)
|
||||
photo_folder_id = result.scalar_one_or_none()
|
||||
if photo_folder_id is None:
|
||||
return False
|
||||
|
||||
# Get the photo's folder path for prefix matching.
|
||||
result = await db.execute(
|
||||
select(Folder.path, Folder.source_root_id).where(Folder.id == photo_folder_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
photo_path, photo_sr_id = row
|
||||
|
||||
# Check source root shares — photo's source root matches a shared root?
|
||||
result = await db.execute(
|
||||
select(FolderShare.folder_id).where(
|
||||
FolderShare.shared_with_id == user.id,
|
||||
FolderShare.folder_type == "source_root",
|
||||
FolderShare.folder_id == photo_sr_id,
|
||||
).limit(1)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
return True
|
||||
|
||||
# Check folder shares — photo's folder is at or below a shared folder?
|
||||
# Single query: join folder_shares → folders to get shared paths, then
|
||||
# check if the photo's path starts with any of them.
|
||||
result = await db.execute(
|
||||
select(Folder.path).where(
|
||||
Folder.id.in_(
|
||||
select(FolderShare.folder_id).where(
|
||||
FolderShare.shared_with_id == user.id,
|
||||
FolderShare.folder_type == "folder",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
for (shared_path,) in result.all():
|
||||
if photo_path == shared_path or photo_path.startswith(shared_path + os.sep):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
Mulita - Photo Management Application
|
||||
Main FastAPI application entry point
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
import logging
|
||||
import os
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, download, nextcloud, nc_webhook
|
||||
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
|
||||
from app.services.cleanup import cleanup_data_integrity
|
||||
from app.services.nextcloud_dav import init_preview_client, close_preview_client
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage application lifecycle"""
|
||||
logger.info("Starting Mulita application...")
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
|
||||
# Pooled httpx client to Nextcloud — keepalive + HTTP/2 means every
|
||||
# thumbnail / Memories-info call after the first reuses one socket
|
||||
# instead of paying TCP+TLS handshake per request.
|
||||
await init_preview_client()
|
||||
|
||||
# First-boot convenience: if there are no source roots in the DB yet,
|
||||
# create one for the default /photos mount so the user sees their
|
||||
# library immediately without configuring anything in the UI.
|
||||
try:
|
||||
await bootstrap_default_source_root()
|
||||
except Exception as e:
|
||||
logger.error(f"Bootstrap source root failed (continuing): {e}")
|
||||
|
||||
# One-shot cleanup of duplicate source_roots / folders left over from
|
||||
# earlier scanner versions that didn't normalize paths. Idempotent.
|
||||
try:
|
||||
await cleanup_data_integrity()
|
||||
except Exception as e:
|
||||
logger.error(f"Startup cleanup failed (continuing): {e}")
|
||||
|
||||
# Start initial scan if configured
|
||||
if settings.scanner.initial_scan_on_start:
|
||||
logger.info("Starting initial library scan...")
|
||||
await start_initial_scan()
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down Mulita application...")
|
||||
await close_preview_client()
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Mulita Photo Management API",
|
||||
description="Self-hosted photo management application inspired by Lightroom",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Configure CORS. The frontend normally talks to the backend through the
|
||||
# nginx (prod) or vite (dev) proxy, so requests are same-origin and never
|
||||
# trip CORS. ALLOWED_ORIGINS in .env controls the fallback for direct
|
||||
# browser access from other origins (LAN IP, reverse proxy under a
|
||||
# different host). Defaults to "*" since this is a single-user homelab
|
||||
# tool; lock it down by setting e.g. ALLOWED_ORIGINS=https://photos.your.tld
|
||||
# in production deployments.
|
||||
_origins = settings.cors_origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
# Wildcard origins can't be combined with credentials per the CORS
|
||||
# spec, so credentials get auto-disabled in that case.
|
||||
allow_credentials=_origins != ["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Session middleware — only used by Authlib to hold PKCE state during
|
||||
# the OIDC round-trip. max_age is short because the cookie is only
|
||||
# meaningful between /auth/oidc/login and /auth/oidc/callback; the app
|
||||
# itself still runs on JWTs.
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.effective_session_secret,
|
||||
session_cookie="mulita_oidc",
|
||||
max_age=600,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
# Mount static files for serving thumbnails (with X-Accel-Redirect support)
|
||||
if os.path.exists("/data/thumbs"):
|
||||
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(sharing.router, prefix="/api/v1", tags=["sharing"])
|
||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||||
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
||||
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
|
||||
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
|
||||
app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"])
|
||||
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
|
||||
app.include_router(search.router, prefix="/api/v1/photos/search", tags=["search"])
|
||||
app.include_router(download.router, prefix="/api/v1/download", tags=["download"])
|
||||
app.include_router(nextcloud.router, prefix="/api/v1/nextcloud", tags=["nextcloud"])
|
||||
app.include_router(nc_webhook.router, prefix="/api/v1/internal", tags=["nc-webhook"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"name": "Mulita Photo Management API",
|
||||
"version": "1.0.0",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for Docker"""
|
||||
return {"status": "healthy"}
|
||||
@@ -1,22 +0,0 @@
|
||||
"""
|
||||
Database models for Mulita
|
||||
"""
|
||||
from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import Folder, SourceRoot
|
||||
from app.models.tags import Tag, PhotoTag
|
||||
from app.models.heaps import Heap, HeapPhoto
|
||||
from app.models.sharing import HeapShare, FolderShare
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Photo',
|
||||
'Folder',
|
||||
'SourceRoot',
|
||||
'Tag',
|
||||
'PhotoTag',
|
||||
'Heap',
|
||||
'HeapPhoto',
|
||||
'HeapShare',
|
||||
'FolderShare',
|
||||
]
|
||||
@@ -1,58 +0,0 @@
|
||||
"""
|
||||
Folder and SourceRoot model definitions
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class SourceRoot(Base):
|
||||
__tablename__ = 'source_roots'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
path = Column(String, unique=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
added_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
folders = relationship("Folder", back_populates="source_root")
|
||||
|
||||
class Folder(Base):
|
||||
__tablename__ = 'folders'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
path = Column(String, unique=True, nullable=False)
|
||||
parent_id = Column(String, ForeignKey('folders.id'))
|
||||
source_root_id = Column(String, ForeignKey('source_roots.id'))
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
photo_count = Column(Integer, default=0)
|
||||
last_scanned = Column(DateTime)
|
||||
|
||||
# "Hide from views" — when true, photos in this folder (and every
|
||||
# descendant folder) are excluded from cross-cutting views like
|
||||
# All Photos, Map, Tags, People, Search and the sidebar counts.
|
||||
# Photos are still scanned, thumbnailed and indexed — they just
|
||||
# stop showing up unless the user navigates directly to a folder
|
||||
# inside the hidden subtree. The effective flag is materialized
|
||||
# onto Photo.is_hidden so queries don't have to walk parent_id.
|
||||
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false')
|
||||
|
||||
# Relationships
|
||||
source_root = relationship("SourceRoot", back_populates="folders")
|
||||
photos = relationship("Photo", backref="folder")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_folders_path', 'path'),
|
||||
Index('ix_folders_parent_id', 'parent_id'),
|
||||
Index('ix_folders_source_root_id', 'source_root_id'),
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
Heap model definitions
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Table, Index
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Association table for many-to-many relationship with additional fields
|
||||
heap_photos = Table(
|
||||
'heap_photos',
|
||||
Base.metadata,
|
||||
Column('heap_id', String, ForeignKey('heaps.id', ondelete='CASCADE'), primary_key=True),
|
||||
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
||||
Column('added_at', DateTime, server_default=func.now()),
|
||||
Column('sort_order', Integer, default=0),
|
||||
Index('ix_heap_photos_heap_id', 'heap_id'),
|
||||
Index('ix_heap_photos_photo_id', 'photo_id'),
|
||||
)
|
||||
|
||||
class Heap(Base):
|
||||
__tablename__ = 'heaps'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
is_active = Column(Boolean, default=False) # For active heap feature
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
|
||||
|
||||
class HeapPhoto:
|
||||
"""Helper class for heap-photo associations (not a table model)"""
|
||||
pass
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Photo model definition
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class Photo(Base):
|
||||
__tablename__ = 'photos'
|
||||
|
||||
# Primary key
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
# Eager-loadable owner relationship. Used by the thumbnail handler so
|
||||
# one photo lookup also yields the NC creds we need to call the
|
||||
# preview endpoint, instead of issuing a second SELECT users WHERE
|
||||
# id=…. No FK change — user_id above already exists.
|
||||
user = relationship("User", lazy="select")
|
||||
|
||||
# File information
|
||||
filepath = Column(String, unique=True, nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
folder_id = Column(String, ForeignKey('folders.id'))
|
||||
file_hash = Column(String, index=True) # SHA-256 hash for duplicate detection
|
||||
|
||||
# Nextcloud fileid for the same file. Set by the scanner when the file
|
||||
# lives under a Nextcloud-rooted SourceRoot. Used by the thumbnail
|
||||
# endpoint to proxy /index.php/core/preview instead of generating
|
||||
# and serving thumbs locally — Nextcloud already maintains previews
|
||||
# for the same source file, and duplicating that work was the bulk
|
||||
# of `/data/thumbs/*`. NULL on legacy / non-NC paths; the handler
|
||||
# falls back to on-disk thumbs when this is unset.
|
||||
nextcloud_fileid = Column(Integer, nullable=True, index=True)
|
||||
|
||||
# Media information
|
||||
media_type = Column(String, nullable=False) # 'photo' | 'video' | 'raw' | 'heic'
|
||||
original_format = Column(String) # 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
|
||||
width = Column(Integer)
|
||||
height = Column(Integer)
|
||||
file_size = Column(Integer)
|
||||
|
||||
# Timestamps
|
||||
taken_at = Column(DateTime) # from EXIF DateTimeOriginal, fallback to file mtime
|
||||
taken_at_source = Column(String) # 'exif' | 'filesystem' | 'manual'
|
||||
added_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
|
||||
# Discard status. The DB column names stay is_trashed/trashed_at to avoid
|
||||
# a migration; only the Python attribute name reflects the rename.
|
||||
is_discarded = Column('is_trashed', Boolean, default=False)
|
||||
discarded_at = Column('trashed_at', DateTime)
|
||||
|
||||
# "Hidden from views" — materialized from Folder.is_hidden walking
|
||||
# the ancestry chain. True iff any ancestor folder (including the
|
||||
# photo's direct folder) is hidden. Cross-cutting queries filter
|
||||
# `AND NOT is_hidden`; per-folder browses ignore the flag so the
|
||||
# user can still open a hidden folder and see its contents. The
|
||||
# column is maintained by two places: the scanner sets it on new
|
||||
# rows, and POST /folders/{id}/hide recomputes it on toggle.
|
||||
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
|
||||
|
||||
# "Capture date is probably wrong" — denormalized from the folder/filename
|
||||
# date-guesser. Set at scan time and recomputed on every taken_at edit so
|
||||
# the filter bar can query it directly. See services/date_guess.py for
|
||||
# the heuristic; kept as a stored column because recomputing on every
|
||||
# list query would mean running the regex stack across thousands of rows.
|
||||
has_date_warning = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
|
||||
|
||||
# Thumbnail paths
|
||||
thumb_small = Column(String) # path to 240px thumb
|
||||
thumb_medium = Column(String) # path to 640px thumb
|
||||
thumb_large = Column(String) # path to 1280px thumb
|
||||
|
||||
# Processing status
|
||||
processing_status = Column(String, default='pending') # 'pending' | 'processing' | 'completed' | 'failed'
|
||||
processing_error = Column(Text)
|
||||
|
||||
# Metadata
|
||||
exif_json = Column(Text) # full EXIF/XMP blob as JSON
|
||||
|
||||
# GPS coordinates extracted from EXIF, in signed decimal degrees
|
||||
# (S latitude / W longitude are negative). Stored as first-class columns
|
||||
# so the Map view and any future location filters can query/index them
|
||||
# without parsing exif_json on every request.
|
||||
latitude = Column(Float)
|
||||
longitude = Column(Float)
|
||||
|
||||
# User-editable fields
|
||||
user_title = Column(String)
|
||||
user_notes = Column(Text)
|
||||
rating = Column(Integer, default=0) # 0-5 stars
|
||||
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
||||
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
|
||||
# concept). is_picked was unified with active-heap membership — picking a
|
||||
# photo just means adding it to the active heap. Both DB columns may still
|
||||
# exist on legacy installs but are no longer read or written.
|
||||
|
||||
# Duplicate detection.
|
||||
#
|
||||
# - file_hash (above): SHA-256 of the raw bytes. Catches byte-identical
|
||||
# copies but not visually-identical re-encodes / resizes / screenshots.
|
||||
# - phash: 16-char hex of a 64-bit perceptual hash, computed by the
|
||||
# thumbs worker from the decoded original frame. Robust to resize and
|
||||
# re-compression — this is what actually identifies "the same photo
|
||||
# saved twice with different JPEG quality".
|
||||
# - duplicate_group_id: shared by every photo in the same duplicate
|
||||
# cluster. Maintained by app.services.duplicates.regroup_duplicates,
|
||||
# not on individual writes — recomputed in batches after scans / on
|
||||
# demand from the Settings panel.
|
||||
# - is_duplicate: derived boolean (group_id IS NOT NULL). Kept as a real
|
||||
# column so the existing PhotoThumbnail badge and /library/stats
|
||||
# duplicates count don't have to change.
|
||||
is_duplicate = Column(Boolean, default=False)
|
||||
phash = Column(String(16), index=True)
|
||||
duplicate_group_id = Column(String, index=True)
|
||||
|
||||
# Live photo support
|
||||
live_photo_video_id = Column(String, ForeignKey('photos.id'))
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index('ix_photos_taken_at', 'taken_at'),
|
||||
Index('ix_photos_folder_id', 'folder_id'),
|
||||
Index('ix_photos_is_trashed', 'is_trashed'),
|
||||
Index('ix_photos_rating', 'rating'),
|
||||
Index('ix_photos_color_label', 'color_label'),
|
||||
Index('ix_photos_media_type', 'media_type'),
|
||||
Index('ix_photos_processing_status', 'processing_status'),
|
||||
Index('ix_photos_lat_lon', 'latitude', 'longitude'),
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""
|
||||
Sharing models — cross-user access to heaps and folders.
|
||||
|
||||
HeapShare grants another user read or read+write access to a heap.
|
||||
FolderShare does the same for a folder (or source root).
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, DateTime, ForeignKey, Index, String, UniqueConstraint, func,
|
||||
)
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class HeapShare(Base):
|
||||
__tablename__ = "heap_shares"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
heap_id = Column(
|
||||
String, ForeignKey("heaps.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
# Denormalized from heap.user_id for fast "shares I own" lookups.
|
||||
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
|
||||
# Lifecycle: 'pending' while the recipient hasn't acted, 'accepted'
|
||||
# once they've Accept'd in the notification bell. Decline deletes the
|
||||
# row outright — see migration 0014 for the backfill of pre-existing
|
||||
# rows to 'accepted' so nothing vanishes from existing sidebars.
|
||||
status = Column(String, nullable=False, default="pending")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
accepted_at = Column(DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("heap_id", "shared_with_id", name="uq_heap_share"),
|
||||
Index("ix_heap_shares_shared_with", "shared_with_id"),
|
||||
Index("ix_heap_shares_heap_id", "heap_id"),
|
||||
Index("ix_heap_shares_shared_with_status", "shared_with_id", "status"),
|
||||
)
|
||||
|
||||
|
||||
class FolderShare(Base):
|
||||
__tablename__ = "folder_shares"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
# Can reference either a Folder.id or a SourceRoot.id.
|
||||
folder_id = Column(String, nullable=False)
|
||||
folder_type = Column(String, nullable=False, default="folder") # 'folder' | 'source_root'
|
||||
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
|
||||
status = Column(String, nullable=False, default="pending")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
accepted_at = Column(DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("folder_id", "shared_with_id", name="uq_folder_share"),
|
||||
Index("ix_folder_shares_shared_with", "shared_with_id"),
|
||||
Index("ix_folder_shares_folder_id", "folder_id"),
|
||||
Index("ix_folder_shares_shared_with_status", "shared_with_id", "status"),
|
||||
)
|
||||
@@ -1,56 +0,0 @@
|
||||
"""
|
||||
Tag model definitions.
|
||||
|
||||
Tags are unified across user-created tags, ML-detected objects, scene
|
||||
labels, and face clusters via the `kind` column. The `photo_tags`
|
||||
association carries per-photo ML metadata (confidence, bounding box,
|
||||
source model).
|
||||
"""
|
||||
from sqlalchemy import Column, String, Float, ForeignKey, Table, Index, UniqueConstraint # noqa: F401
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Association table for many-to-many relationship
|
||||
photo_tags = Table(
|
||||
'photo_tags',
|
||||
Base.metadata,
|
||||
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
||||
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
|
||||
# ML metadata — null for user-applied tags
|
||||
Column('confidence', Float, nullable=True),
|
||||
Column('bbox', JSONB, nullable=True), # [x1, y1, x2, y2] normalized 0-1
|
||||
Column('source', String, nullable=True), # null for user-applied tags
|
||||
Index('ix_photo_tags_photo_id', 'photo_id'),
|
||||
Index('ix_photo_tags_tag_id', 'tag_id'),
|
||||
)
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = 'tags'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('name', 'kind', 'user_id', name='uq_tags_name_kind_user'),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False, index=True)
|
||||
color = Column(String) # Hex color code for UI display
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
|
||||
# Tag classification
|
||||
kind = Column(String, nullable=False, default='user', index=True)
|
||||
# kind values: 'user' | 'content_type'
|
||||
|
||||
# Which producer wrote this tag (null for user-created)
|
||||
source = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
photos = relationship("Photo", secondary=photo_tags, backref="tags")
|
||||
|
||||
|
||||
class PhotoTag:
|
||||
"""Helper class for photo-tag associations (not a table model)"""
|
||||
pass
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
User model definition
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
email = Column(String, unique=True, nullable=True)
|
||||
# Nullable: OIDC-only users have no local password. Local accounts
|
||||
# still always have one.
|
||||
hashed_password = Column(String, nullable=True)
|
||||
role = Column(String, nullable=False, default='user') # 'admin' | 'user'
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Absolute path to this user's photo directory (e.g., "/photos/daniel")
|
||||
media_path = Column(String, nullable=False)
|
||||
|
||||
# OIDC identity — populated when a user signs in via Authentik (or any
|
||||
# other OIDC provider later). `oidc_sub` is stable per provider, so
|
||||
# lookups key on (oidc_issuer, oidc_sub). NULL for password-only users.
|
||||
oidc_issuer = Column(String, nullable=True)
|
||||
oidc_sub = Column(String, nullable=True)
|
||||
|
||||
# Profile bits that can come from OIDC claims or be filled in later.
|
||||
# avatar_url wins over Gravatar when set; the /auth/me response
|
||||
# computes the final avatar URL for the frontend.
|
||||
avatar_url = Column(String, nullable=True)
|
||||
display_name = Column(String, nullable=True)
|
||||
|
||||
# Nextcloud integration. `nextcloud_username` defaults to the
|
||||
# `preferred_username` OIDC claim on first login but can be overridden
|
||||
# in Settings (the local mule-image username doesn't always match the
|
||||
# Nextcloud user — e.g. authentik `dtoro` ↔ Nextcloud `admin`).
|
||||
# `nextcloud_app_password_enc` is the user's Nextcloud app password
|
||||
# (created from Nextcloud → Settings → Security), Fernet-encrypted at
|
||||
# rest with a key derived from settings.secret_key. Used as HTTP Basic
|
||||
# auth on outgoing WebDAV calls when the user mutates a file under
|
||||
# their Nextcloud-rooted SourceRoot.
|
||||
nextcloud_username = Column(String, nullable=True, index=True)
|
||||
nextcloud_app_password_enc = Column(String, nullable=True)
|
||||
@@ -1,260 +0,0 @@
|
||||
"""
|
||||
Admin router — user management and app configuration.
|
||||
All endpoints require admin role.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import SourceRoot
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user" # 'admin' | 'user'
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
new_password: Optional[str] = None
|
||||
|
||||
class UserDetailResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
media_path: str
|
||||
created_at: Optional[str]
|
||||
photo_count: int = 0
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: List[UserDetailResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/users", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users with their photo counts."""
|
||||
result = await db.execute(select(User).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
|
||||
user_list = []
|
||||
for u in users:
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == u.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
user_list.append(UserDetailResponse(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
media_path=u.media_path,
|
||||
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||
photo_count=photo_count,
|
||||
))
|
||||
|
||||
return UserListResponse(users=user_list, total=len(user_list))
|
||||
|
||||
|
||||
@router.post("/users", status_code=201, response_model=UserDetailResponse)
|
||||
async def create_user(
|
||||
body: CreateUserRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new user. Creates their media directory and source root."""
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
if len(body.username.strip()) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
|
||||
# Check for duplicate username
|
||||
existing = await db.execute(
|
||||
select(User).where(User.username == body.username.strip())
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="Username already taken")
|
||||
|
||||
media_path = os.path.join(settings.photo_dirs, body.username.strip())
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
hashed_password=hash_password(body.password),
|
||||
role=body.role,
|
||||
media_path=media_path,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # get user.id before creating source root
|
||||
|
||||
source_root = SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(source_root)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin '{admin.username}' created user '{user.username}' (role={user.role})")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single user's details."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=photo_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UpdateUserRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a user's role, active status, or password."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if body.role is not None:
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
# Prevent demoting the last admin
|
||||
if user.role == "admin" and body.role == "user":
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
||||
user.role = body.role
|
||||
|
||||
if body.is_active is not None:
|
||||
# Prevent deactivating the last admin
|
||||
if user.role == "admin" and not body.is_active:
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate the last admin")
|
||||
user.is_active = body.is_active
|
||||
|
||||
if body.new_password is not None:
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
user.hashed_password = hash_password(body.new_password)
|
||||
|
||||
await db.commit()
|
||||
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=photo_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soft-delete a user by deactivating them. Media is preserved."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# Prevent deleting the last admin
|
||||
if user.role == "admin":
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
|
||||
|
||||
user.is_active = False
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'")
|
||||
return {"status": "ok", "detail": f"User '{user.username}' deactivated"}
|
||||
@@ -1,552 +0,0 @@
|
||||
"""
|
||||
Authentication router — login, token refresh, profile, first-run setup,
|
||||
and optional OIDC (Authentik) sign-in.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from app.auth_oidc import get_client as get_oidc_client, is_enabled as oidc_is_enabled, provider_label, PROVIDER_NAME
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.folders import SourceRoot
|
||||
from app.services.gravatar import gravatar_url
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: Optional[str]
|
||||
avatar_url: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
# Nextcloud integration — username override (defaults to OIDC
|
||||
# preferred_username) and a flag for whether the user has stored
|
||||
# an app password. Cleartext passwords are never serialized.
|
||||
nextcloud_username: Optional[str] = None
|
||||
has_nextcloud_app_password: bool = False
|
||||
|
||||
|
||||
class UpdateMeRequest(BaseModel):
|
||||
"""PATCH /me payload. Every field is optional — only what's set
|
||||
gets touched. Setting `nextcloud_app_password` to "" clears it."""
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_app_password: Optional[str] = None
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class OidcConfig(BaseModel):
|
||||
enabled: bool
|
||||
label: str
|
||||
login_url: str
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
# None when OIDC is disabled / not configured — the frontend uses
|
||||
# that to hide the SSO button.
|
||||
oidc: Optional[OidcConfig] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def serialize_user(user: User) -> UserResponse:
|
||||
"""Build the user-facing payload, computing the avatar URL with the
|
||||
`provider picture > Gravatar > None` fallback chain."""
|
||||
avatar = user.avatar_url or gravatar_url(user.email)
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
avatar_url=avatar,
|
||||
display_name=user.display_name,
|
||||
nextcloud_username=user.nextcloud_username,
|
||||
has_nextcloud_app_password=bool(user.nextcloud_app_password_enc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config():
|
||||
"""Public — tells the frontend which login options to render.
|
||||
|
||||
Returns `oidc: null` when OIDC is disabled or not fully configured,
|
||||
so the login page can hide the SSO button without a round-trip to
|
||||
the IdP. The `login_url` is browser-navigable (full redirect); it
|
||||
starts the Authlib flow that sets the PKCE cookie.
|
||||
"""
|
||||
if not oidc_is_enabled():
|
||||
return AuthConfigResponse(oidc=None)
|
||||
return AuthConfigResponse(
|
||||
oidc=OidcConfig(
|
||||
enabled=True,
|
||||
label=provider_label(),
|
||||
login_url="/api/v1/auth/oidc/login",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate with username + password, receive JWT tokens."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == body.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# OIDC-only users (hashed_password IS NULL) can't sign in via this
|
||||
# endpoint; they must go through the SSO flow. Treat as auth failure
|
||||
# so we don't leak account existence.
|
||||
if (
|
||||
user is None
|
||||
or not user.hashed_password
|
||||
or not verify_password(body.password, user.hashed_password)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange a valid refresh token for a new access + refresh pair."""
|
||||
try:
|
||||
payload = decode_token(body.refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise ValueError("not a refresh token")
|
||||
user_id = payload["sub"]
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or deactivated",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the authenticated user's profile."""
|
||||
return serialize_user(current_user)
|
||||
|
||||
|
||||
_NC_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._@-]{1,64}$")
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_me(
|
||||
body: UpdateMeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update the authenticated user's Nextcloud integration settings.
|
||||
|
||||
`nextcloud_username` overrides the OIDC `preferred_username` default
|
||||
so e.g. the local mule-image user `dtoro` can map to Nextcloud user
|
||||
`admin`. `nextcloud_app_password` is encrypted at rest via the
|
||||
Fernet helper in `services/secrets.py`; passing an empty string
|
||||
clears it.
|
||||
"""
|
||||
from app.services.secrets import encrypt
|
||||
|
||||
changed = False
|
||||
if body.nextcloud_username is not None:
|
||||
candidate = body.nextcloud_username.strip()
|
||||
if candidate and not _NC_USERNAME_RE.match(candidate):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nextcloud username")
|
||||
current_user.nextcloud_username = candidate or None
|
||||
changed = True
|
||||
|
||||
if body.nextcloud_app_password is not None:
|
||||
if body.nextcloud_app_password == "":
|
||||
current_user.nextcloud_app_password_enc = None
|
||||
else:
|
||||
current_user.nextcloud_app_password_enc = encrypt(body.nextcloud_app_password)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return serialize_user(current_user)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change the authenticated user's password."""
|
||||
if not current_user.hashed_password or not verify_password(
|
||||
body.current_password, current_user.hashed_password
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
current_user.hashed_password = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/setup", response_model=TokenResponse, status_code=201)
|
||||
async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""First-run only: create the initial admin account.
|
||||
|
||||
Returns 409 if any user already exists. This endpoint is
|
||||
unauthenticated by design — it can only run once.
|
||||
"""
|
||||
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||
if count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Setup already completed — users exist",
|
||||
)
|
||||
|
||||
if len(body.username.strip()) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
|
||||
# Every user — including the initial admin — gets their own subfolder
|
||||
# under the photo mount root. Nobody owns the root directory itself.
|
||||
media_path = os.path.join(settings.photo_dirs, body.username.strip())
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
hashed_password=hash_password(body.password),
|
||||
role="admin",
|
||||
media_path=media_path,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # get user.id before creating source root
|
||||
|
||||
source_root = SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(source_root)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Initial admin account created: {user.username}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint: returns whether setup has been completed.
|
||||
|
||||
The frontend calls this to decide whether to show the setup page
|
||||
or the login page.
|
||||
"""
|
||||
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||
return {"setup_completed": count > 0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC (Authentik) sign-in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_USERNAME_SANITIZER = re.compile(r"[^a-zA-Z0-9_.-]+")
|
||||
|
||||
|
||||
def _derive_username(claims: dict, existing_usernames: set[str]) -> str:
|
||||
"""Pick a local username from OIDC claims.
|
||||
|
||||
Order of preference:
|
||||
1. `preferred_username` claim (Authentik's usual choice)
|
||||
2. local-part of `email`
|
||||
3. `sub` claim (always present)
|
||||
|
||||
Strips characters the rest of the app doesn't like in paths/URLs,
|
||||
trims to 50 chars (User.username column limit), and appends a short
|
||||
suffix on collision so two Authentik users can't land on the same
|
||||
local row.
|
||||
"""
|
||||
raw = (
|
||||
claims.get("preferred_username")
|
||||
or (claims.get("email") or "").split("@", 1)[0]
|
||||
or claims.get("sub")
|
||||
or "user"
|
||||
)
|
||||
base = _USERNAME_SANITIZER.sub("", str(raw)).strip("._-") or "user"
|
||||
base = base[:40]
|
||||
candidate = base
|
||||
suffix = 0
|
||||
while candidate in existing_usernames:
|
||||
suffix += 1
|
||||
candidate = f"{base}-{suffix}"[:50]
|
||||
return candidate
|
||||
|
||||
|
||||
def _frontend_origin(request: Request) -> str:
|
||||
"""Best guess at where the SPA lives so the callback redirect lands
|
||||
back on the app origin. Uses the configured redirect URI's scheme +
|
||||
host (strips /api/... path) when available, falling back to the
|
||||
request's own origin."""
|
||||
if settings.oidc_redirect_uri:
|
||||
parsed = urlparse(settings.oidc_redirect_uri)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
return f"{request.url.scheme}://{request.url.netloc}"
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request):
|
||||
"""Start the OIDC flow — redirect to Authentik's authorization URL."""
|
||||
if not oidc_is_enabled():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
|
||||
client = get_oidc_client()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=500, detail="OIDC client not configured")
|
||||
|
||||
redirect_uri = settings.oidc_redirect_uri
|
||||
return await client.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Handle the OIDC redirect — exchange code, provision/link user,
|
||||
issue our own JWTs, bounce back to the SPA."""
|
||||
if not oidc_is_enabled():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
|
||||
client = get_oidc_client()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=500, detail="OIDC client not configured")
|
||||
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except Exception as exc:
|
||||
logger.warning("OIDC callback: authorize_access_token failed: %s", exc)
|
||||
return _oidc_error_redirect(request, "oidc_exchange_failed")
|
||||
|
||||
# `parse_id_token` verifies signature + nonce; `userinfo` fills in
|
||||
# claims some IdPs don't put in the ID token (e.g. picture). We
|
||||
# merge both, preferring userinfo when both are present.
|
||||
claims = dict(token.get("userinfo") or {})
|
||||
if not claims:
|
||||
try:
|
||||
claims = dict(await client.userinfo(token=token))
|
||||
except Exception:
|
||||
claims = {}
|
||||
id_token_claims = token.get("id_token_claims") or {}
|
||||
for k, v in id_token_claims.items():
|
||||
claims.setdefault(k, v)
|
||||
|
||||
sub = claims.get("sub")
|
||||
if not sub:
|
||||
logger.warning("OIDC callback: claims missing `sub` — %r", claims)
|
||||
return _oidc_error_redirect(request, "oidc_missing_sub")
|
||||
|
||||
issuer = claims.get("iss") or (settings.oidc_issuer or "").rstrip("/")
|
||||
email = claims.get("email")
|
||||
display_name = claims.get("name") or claims.get("preferred_username")
|
||||
picture = claims.get("picture")
|
||||
groups = claims.get("groups") or []
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
|
||||
admin_groups = set(settings.oidc_admin_group_list)
|
||||
role = "admin" if admin_groups and admin_groups.intersection(groups) else "user"
|
||||
|
||||
# 1. Match by (issuer, sub) first — stable identity key.
|
||||
user = (await db.execute(
|
||||
select(User).where(
|
||||
User.oidc_issuer == issuer,
|
||||
User.oidc_sub == sub,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# 2. Fall back to email so a pre-existing local account can be
|
||||
# linked on first SSO login (homelab admin keeps their row).
|
||||
if user is None and email:
|
||||
user = (await db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# 3. Last-resort link by preferred_username. Off by default; only
|
||||
# used in trusted single-tenant setups where local accounts
|
||||
# predate OIDC and never collected email (the app has no UI for
|
||||
# it). Guarded by OIDC_LINK_BY_USERNAME to avoid hijacking
|
||||
# accounts in shared instances.
|
||||
if user is None and settings.oidc_link_by_username:
|
||||
preferred = claims.get("preferred_username")
|
||||
if preferred:
|
||||
user = (await db.execute(
|
||||
select(User).where(User.username == preferred)
|
||||
)).scalar_one_or_none()
|
||||
if user is not None:
|
||||
logger.info(
|
||||
"OIDC linked existing user %s by preferred_username",
|
||||
preferred,
|
||||
)
|
||||
|
||||
if user is None:
|
||||
if not settings.oidc_allow_signup:
|
||||
logger.info("OIDC signup disabled — rejecting unknown sub=%s email=%s", sub, email)
|
||||
return _oidc_error_redirect(request, "oidc_signup_disabled")
|
||||
|
||||
# JIT provision.
|
||||
existing = {
|
||||
u for (u,) in (await db.execute(select(User.username))).all()
|
||||
}
|
||||
username = _derive_username(claims, existing)
|
||||
media_path = os.path.join(settings.photo_dirs, username)
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
hashed_password=None,
|
||||
role=role,
|
||||
is_active=True,
|
||||
media_path=media_path,
|
||||
oidc_issuer=issuer,
|
||||
oidc_sub=sub,
|
||||
avatar_url=picture,
|
||||
display_name=display_name,
|
||||
# Default the Nextcloud username from preferred_username so
|
||||
# the common case "same name on both sides" needs zero
|
||||
# configuration. Override is exposed in Settings for the
|
||||
# mismatch case (e.g. authentik dtoro ↔ Nextcloud admin).
|
||||
nextcloud_username=(claims.get("preferred_username") or None),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
db.add(SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
))
|
||||
await db.commit()
|
||||
logger.info("OIDC JIT-created user %s (role=%s)", user.username, role)
|
||||
else:
|
||||
# Refresh profile bits + link identity if needed. We do *not*
|
||||
# demote admins created locally; only touch role when admin
|
||||
# group mapping is configured.
|
||||
changed = False
|
||||
if user.oidc_sub != sub or user.oidc_issuer != issuer:
|
||||
user.oidc_issuer = issuer
|
||||
user.oidc_sub = sub
|
||||
changed = True
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
changed = True
|
||||
if display_name and user.display_name != display_name:
|
||||
user.display_name = display_name
|
||||
changed = True
|
||||
if picture and user.avatar_url != picture:
|
||||
user.avatar_url = picture
|
||||
changed = True
|
||||
# Backfill nextcloud_username on first OIDC login for users that
|
||||
# predate the column. NEVER overwrites a value the user already
|
||||
# set in Settings — once the override is non-null, it wins.
|
||||
if not user.nextcloud_username:
|
||||
preferred = claims.get("preferred_username")
|
||||
if preferred:
|
||||
user.nextcloud_username = preferred
|
||||
changed = True
|
||||
if admin_groups:
|
||||
new_role = "admin" if admin_groups.intersection(groups) else "user"
|
||||
if user.role != new_role:
|
||||
user.role = new_role
|
||||
changed = True
|
||||
if not user.is_active:
|
||||
# Don't resurrect a deactivated account — surface an error.
|
||||
logger.info("OIDC login rejected — user %s is deactivated", user.username)
|
||||
return _oidc_error_redirect(request, "oidc_deactivated")
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
# Mint our own JWTs and bounce back to the SPA. Tokens ride in the
|
||||
# URL fragment-free for simplicity; the frontend callback page
|
||||
# strips them from the location bar immediately.
|
||||
access = create_access_token(user.id, user.role)
|
||||
refresh = create_refresh_token(user.id)
|
||||
|
||||
params = urlencode({"access_token": access, "refresh_token": refresh})
|
||||
target = f"{_frontend_origin(request)}/auth/callback?{params}"
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
|
||||
|
||||
def _oidc_error_redirect(request: Request, code: str) -> RedirectResponse:
|
||||
"""Bounce back to the SPA with an `error=` query so the login page
|
||||
can render something meaningful instead of a stack trace."""
|
||||
target = f"{_frontend_origin(request)}/auth/callback?error={code}"
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
@@ -1,117 +0,0 @@
|
||||
"""
|
||||
Discard API router
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
from app.services.nextcloud_dav import delete_for_user, is_nextcloud_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("")
|
||||
async def list_discarded(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""List discarded photos"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return photos
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Restore photos from the discard pile"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id))
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
for photo in photos:
|
||||
photo.is_discarded = False
|
||||
photo.discarded_at = None
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "restored": len(photos)}
|
||||
|
||||
@router.delete("/empty")
|
||||
async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Permanently delete all discarded photos and unlink their files from
|
||||
disk. Failures on individual files are logged but don't abort the batch.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos, current_user)
|
||||
|
||||
|
||||
@router.delete("")
|
||||
async def delete_discarded(
|
||||
photo_ids: list[str] = Body(..., embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Permanently delete a specific subset of discarded photos. The photos
|
||||
must already be in the discard pile — non-discarded ids are skipped so
|
||||
this can never bypass the soft-delete safety net.
|
||||
"""
|
||||
if not photo_ids:
|
||||
return {"status": "success", "deleted": 0, "file_errors": 0}
|
||||
result = await db.execute(
|
||||
select(Photo).where(
|
||||
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos, current_user)
|
||||
|
||||
|
||||
async def _permanently_delete(db: AsyncSession, photos: list[Photo], user: User) -> dict:
|
||||
"""Shared helper: unlink files for the given photos and delete their
|
||||
rows. Per-file errors are counted but don't abort the batch.
|
||||
|
||||
For files inside a Nextcloud-rooted SourceRoot the unlink is dispatched
|
||||
through Nextcloud's WebDAV `DELETE` so Nextcloud moves the file into
|
||||
the user's trashbin and updates `oc_filecache`. For everything else we
|
||||
fall back to plain `os.unlink`.
|
||||
"""
|
||||
deleted = 0
|
||||
file_errors = 0
|
||||
for photo in photos:
|
||||
try:
|
||||
if photo.filepath:
|
||||
if is_nextcloud_path(photo.filepath):
|
||||
# WebDAV DELETE — Nextcloud moves to trashbin and
|
||||
# updates oc_filecache. The bind mount will then
|
||||
# reflect the file's absence (Nextcloud writes
|
||||
# synchronously). 404 from NC is treated as already
|
||||
# gone (idempotent).
|
||||
delete_for_user(user, photo.filepath)
|
||||
elif os.path.exists(photo.filepath):
|
||||
os.unlink(photo.filepath)
|
||||
except HTTPException as e:
|
||||
# WebDAV-side error — surface to caller via the file_errors
|
||||
# counter rather than aborting the whole batch.
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to delete {photo.filepath} via Nextcloud: {e.detail}")
|
||||
continue
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
||||
await db.delete(photo)
|
||||
deleted += 1
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"deleted": deleted,
|
||||
"file_errors": file_errors,
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
"""
|
||||
Download router — streams a .zip of every photo in a folder (recursively)
|
||||
or a heap back to the browser.
|
||||
|
||||
Auth: both endpoints accept the regular Authorization header *or* a
|
||||
``?token=JWT`` query string, mirroring the media endpoints. That lets the
|
||||
frontend trigger a download with a plain ``<a href>`` (which can't set a
|
||||
header), keeping the client side a one-liner.
|
||||
|
||||
Implementation: we build the zip into a ``NamedTemporaryFile`` and then
|
||||
stream its bytes back, deleting the temp file on the way out. Stored
|
||||
(uncompressed) mode because photos and videos are already compressed —
|
||||
deflating them again just burns CPU for a fraction of a percent. For
|
||||
very large libraries the temp-file route is mildly wasteful vs. a true
|
||||
streaming zip (zipstream-ng etc), but it avoids a new dependency and
|
||||
handles arbitrary folder sizes without blowing out RAM.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user_media
|
||||
from app.models import Folder, Heap, Photo, SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Strip characters that Content-Disposition or Windows filesystems
|
||||
would choke on. Keeps the download's filename readable without
|
||||
needing any escaping on the client side."""
|
||||
cleaned = re.sub(r'[\\/:*?"<>|\r\n\t]', '_', name).strip().strip('.')
|
||||
return cleaned or 'download'
|
||||
|
||||
|
||||
async def _collect_folder_photos(
|
||||
folder_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> tuple[str, str, List[Photo]]:
|
||||
"""Resolve a folder or source-root id → (base_path, display_name,
|
||||
photos). ``base_path`` is the prefix we strip off each photo's
|
||||
filepath when naming zip entries, so the archive mirrors the user's
|
||||
on-disk structure under that folder.
|
||||
"""
|
||||
folder = (await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
base_path: str
|
||||
display_name: str
|
||||
if folder is not None:
|
||||
base_path = os.path.normpath(folder.path)
|
||||
display_name = folder.name or os.path.basename(base_path)
|
||||
else:
|
||||
sr = (await db.execute(
|
||||
select(SourceRoot).where(
|
||||
SourceRoot.id == folder_id,
|
||||
SourceRoot.user_id == user.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if sr is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
base_path = os.path.normpath(sr.path)
|
||||
display_name = sr.name or os.path.basename(base_path)
|
||||
|
||||
# Every photo whose filepath is at or below the base path — matches
|
||||
# the same prefix convention folders.py uses for recursive deletes.
|
||||
descendant_prefix = base_path.rstrip(os.sep) + os.sep
|
||||
result = await db.execute(
|
||||
select(Photo).where(
|
||||
Photo.user_id == user.id,
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
(Photo.filepath == base_path) | (Photo.filepath.like(descendant_prefix + '%')),
|
||||
)
|
||||
)
|
||||
photos = list(result.scalars().all())
|
||||
return base_path, display_name, photos
|
||||
|
||||
|
||||
def _build_zip(
|
||||
photos: List[Photo],
|
||||
arcname_fn,
|
||||
) -> tempfile.NamedTemporaryFile:
|
||||
"""Write ``photos`` into a fresh ZIP_STORED temp file.
|
||||
|
||||
``arcname_fn(photo, used_names)`` returns the entry name to use for
|
||||
the given photo; the caller supplies it because folder downloads
|
||||
want path-preserving names while heap downloads flatten to bare
|
||||
filenames (with a collision suffix).
|
||||
"""
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
||||
try:
|
||||
used: set[str] = set()
|
||||
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_STORED, allowZip64=True) as zf:
|
||||
for p in photos:
|
||||
if not p.filepath or not os.path.exists(p.filepath):
|
||||
# Silent skip: the scanner may have indexed files
|
||||
# that have since been moved / unlinked by a shell.
|
||||
continue
|
||||
name = arcname_fn(p, used)
|
||||
used.add(name)
|
||||
try:
|
||||
zf.write(p.filepath, name)
|
||||
except OSError as e:
|
||||
logger.warning(f"Skipping {p.filepath} in zip: {e}")
|
||||
tmp.close()
|
||||
return tmp
|
||||
except Exception:
|
||||
tmp.close()
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _stream_and_cleanup(path: str):
|
||||
"""Yield the temp zip in 1 MiB chunks and unlink it when the
|
||||
iterator is exhausted (or GC'd, if the client disconnects early)."""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
while True:
|
||||
chunk = f.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError as e:
|
||||
logger.debug(f"Temp zip cleanup failed for {path}: {e}")
|
||||
|
||||
|
||||
def _dedupe(name: str, used: set[str]) -> str:
|
||||
"""Return ``name`` (or ``name (2)``, ``name (3)`` ...) such that the
|
||||
result doesn't collide with anything in ``used``. Needed for heap
|
||||
downloads where two members can have identical filenames from
|
||||
different folders."""
|
||||
if name not in used:
|
||||
return name
|
||||
stem, ext = os.path.splitext(name)
|
||||
n = 2
|
||||
while True:
|
||||
cand = f"{stem} ({n}){ext}"
|
||||
if cand not in used:
|
||||
return cand
|
||||
n += 1
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}")
|
||||
async def download_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_media),
|
||||
):
|
||||
"""Zip every (non-discarded) photo under a folder/source-root and
|
||||
stream it back. Entries preserve the folder structure relative to
|
||||
the downloaded root so the resulting archive is a faithful snapshot.
|
||||
"""
|
||||
base_path, display_name, photos = await _collect_folder_photos(
|
||||
folder_id, current_user, db
|
||||
)
|
||||
if not photos:
|
||||
raise HTTPException(status_code=404, detail="No photos to download")
|
||||
|
||||
def arcname(p: Photo, _used: set[str]) -> str:
|
||||
# Relative path from the download root, falling back to the
|
||||
# bare filename if the photo somehow lives outside base_path.
|
||||
abs_path = os.path.normpath(p.filepath)
|
||||
if abs_path.startswith(base_path + os.sep):
|
||||
rel = abs_path[len(base_path) + 1:]
|
||||
elif abs_path == base_path:
|
||||
rel = os.path.basename(abs_path)
|
||||
else:
|
||||
rel = p.filename or os.path.basename(abs_path)
|
||||
# Nest everything under display_name so users see one top-level
|
||||
# folder inside the zip rather than loose files.
|
||||
return os.path.join(_safe_filename(display_name), rel)
|
||||
|
||||
tmp = _build_zip(photos, arcname)
|
||||
filename = _safe_filename(display_name) + '.zip'
|
||||
return StreamingResponse(
|
||||
_stream_and_cleanup(tmp.name),
|
||||
media_type='application/zip',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="{filename}"',
|
||||
'Content-Length': str(os.path.getsize(tmp.name)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/heaps/{heap_id}")
|
||||
async def download_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_media),
|
||||
):
|
||||
"""Zip every photo in a heap. Heaps are flat collections, so entries
|
||||
use the original filename (with a ``(2)`` collision suffix when
|
||||
two members share a name)."""
|
||||
heap = (await db.execute(
|
||||
select(Heap).where(Heap.id == heap_id, Heap.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if heap is None:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Photo)
|
||||
.join(heap_photos, heap_photos.c.photo_id == Photo.id)
|
||||
.where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
photos = list(result.scalars().all())
|
||||
if not photos:
|
||||
raise HTTPException(status_code=404, detail="Heap is empty")
|
||||
|
||||
def arcname(p: Photo, used: set[str]) -> str:
|
||||
bare = p.filename or os.path.basename(p.filepath or 'photo')
|
||||
entry = os.path.join(_safe_filename(heap.name), _dedupe(bare, used))
|
||||
return entry
|
||||
|
||||
tmp = _build_zip(photos, arcname)
|
||||
filename = _safe_filename(heap.name) + '.zip'
|
||||
return StreamingResponse(
|
||||
_stream_and_cleanup(tmp.name),
|
||||
media_type='application/zip',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="{filename}"',
|
||||
'Content-Length': str(os.path.getsize(tmp.name)),
|
||||
},
|
||||
)
|
||||
@@ -1,584 +0,0 @@
|
||||
"""
|
||||
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
|
||||
in .env → backend bootstrap on startup) — adding or removing one is a
|
||||
docker-compose change. Sub-folders inside a source root can be created,
|
||||
renamed, and deleted from the UI; those changes are mirrored to disk.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update as sql_update, delete as sql_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Folder, SourceRoot, Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_folder
|
||||
from app.services.nextcloud_dav import (
|
||||
delete_for_user as nc_delete,
|
||||
is_nextcloud_path,
|
||||
mkcol_for_user,
|
||||
move_for_user as nc_move,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FolderRename(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: str # Folder.id (NOT a SourceRoot id)
|
||||
|
||||
|
||||
class FolderHide(BaseModel):
|
||||
hidden: bool
|
||||
|
||||
|
||||
def _validate_folder_name(name: str) -> str:
|
||||
"""Trim + sanity-check a folder name. Rejects names that contain a
|
||||
path separator or that resolve to a parent traversal — those would
|
||||
let the user escape the parent directory through this endpoint.
|
||||
"""
|
||||
name = (name or '').strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Name cannot be empty")
|
||||
if '/' in name or '\\' in name or name in ('.', '..'):
|
||||
raise HTTPException(status_code=400, detail="Invalid folder name")
|
||||
return name
|
||||
|
||||
@router.get("")
|
||||
async def get_folders(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Get all source folders"""
|
||||
# Get source roots instead of regular folders
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id))
|
||||
source_roots = result.scalars().all()
|
||||
|
||||
folders_list = []
|
||||
for root in source_roots:
|
||||
# Get photo count for this source root
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.source_root_id == root.id)
|
||||
)
|
||||
folders = folder_result.scalars().all()
|
||||
photo_count = sum(f.photo_count for f in folders)
|
||||
|
||||
folders_list.append({
|
||||
"id": root.id,
|
||||
"name": root.name or os.path.basename(root.path),
|
||||
"path": root.path,
|
||||
"photo_count": photo_count
|
||||
})
|
||||
|
||||
return {"folders": folders_list}
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_folder_tree(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Recursive folder tree, one root per active SourceRoot. The tree
|
||||
starts at the Folder row matching the SourceRoot.path (the scanner
|
||||
creates one for every walked directory), with the SourceRoot's
|
||||
display name overlaid so the top-level entry reads as "Library"
|
||||
instead of "/photos".
|
||||
|
||||
Returns a list of root nodes; each node has:
|
||||
{ id, name, path, photo_count, children: [...] }
|
||||
|
||||
photo_count is **recursive** — every node reports the total non-
|
||||
discarded photos in its own subtree, so the badge matches what the
|
||||
user sees when they click the row (which also filters recursively).
|
||||
|
||||
The stored Folder.photo_count column is intentionally NOT trusted;
|
||||
the scanner's bookkeeping for that field has historically been
|
||||
wrong (it leaks the global total into whichever folder os.walk
|
||||
visited last). We compute counts here from the photos table.
|
||||
|
||||
Sub-folders that physically belong to the same source root but
|
||||
weren't created on disk (e.g. the / row the scanner sometimes
|
||||
creates as a parent walk) are skipped via path-prefix filtering.
|
||||
"""
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id) # noqa: E712
|
||||
)
|
||||
source_roots = sr_result.scalars().all()
|
||||
|
||||
out = []
|
||||
for sr in source_roots:
|
||||
# Folders physically inside this source root, by path prefix.
|
||||
prefix = os.path.normpath(sr.path).rstrip(os.sep)
|
||||
f_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.source_root_id == sr.id,
|
||||
# Either the folder IS the source root, or it sits beneath it.
|
||||
(Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%'))
|
||||
)
|
||||
)
|
||||
folders = f_result.scalars().all()
|
||||
if not folders:
|
||||
continue
|
||||
|
||||
# Direct (non-recursive) photo counts per folder, computed from
|
||||
# the photos table. Excludes discarded AND hidden photos so the
|
||||
# sidebar badge matches the "All Photos"-style cross-cutting
|
||||
# views. Users can still click into a hidden folder and see its
|
||||
# contents; the badge count simply won't reflect those photos.
|
||||
folder_ids = [f.id for f in folders]
|
||||
direct_counts: dict[str, int] = {}
|
||||
if folder_ids:
|
||||
count_result = await db.execute(
|
||||
select(Photo.folder_id, func.count(Photo.id))
|
||||
.where(
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
Photo.is_hidden == False, # noqa: E712
|
||||
Photo.folder_id.in_(folder_ids),
|
||||
)
|
||||
.group_by(Photo.folder_id)
|
||||
)
|
||||
direct_counts = {row[0]: int(row[1]) for row in count_result.all()}
|
||||
|
||||
# Build a path → node map so we can attach children regardless of
|
||||
# parent_id consistency. We populate photo_count with the direct
|
||||
# count first, then accumulate descendants in a post-order pass.
|
||||
# `is_hidden` on each node carries the user-set folder flag (NOT
|
||||
# the effective ancestry flag) so the frontend can render the
|
||||
# hidden icon on the exact folder the user toggled.
|
||||
nodes = {
|
||||
f.path: {
|
||||
"id": f.id,
|
||||
"name": f.name or os.path.basename(f.path),
|
||||
"path": f.path,
|
||||
"photo_count": direct_counts.get(f.id, 0),
|
||||
"is_hidden": bool(f.is_hidden),
|
||||
"children": [],
|
||||
}
|
||||
for f in folders
|
||||
}
|
||||
|
||||
root_node = None
|
||||
for f in folders:
|
||||
node = nodes[f.path]
|
||||
if f.path == prefix:
|
||||
root_node = node
|
||||
# Override the display name with the source root's label.
|
||||
node["name"] = sr.name or node["name"]
|
||||
continue
|
||||
parent_path = os.path.normpath(os.path.dirname(f.path))
|
||||
parent = nodes.get(parent_path)
|
||||
if parent is not None:
|
||||
parent["children"].append(node)
|
||||
# If parent isn't in the set (orphan from a partial scan), drop
|
||||
# the node — it can't be rendered consistently.
|
||||
|
||||
if root_node is not None:
|
||||
# Sort children alphabetically at every level.
|
||||
def sort_recursive(n):
|
||||
n["children"].sort(key=lambda c: c["name"].lower())
|
||||
for c in n["children"]:
|
||||
sort_recursive(c)
|
||||
sort_recursive(root_node)
|
||||
|
||||
# Post-order: each node's recursive count is its own direct
|
||||
# count plus the sum of every descendant's recursive count.
|
||||
def accumulate(n) -> int:
|
||||
total = n["photo_count"]
|
||||
for c in n["children"]:
|
||||
total += accumulate(c)
|
||||
n["photo_count"] = total
|
||||
return total
|
||||
accumulate(root_node)
|
||||
|
||||
out.append(root_node)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
body: FolderRename,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename a folder. Two cases:
|
||||
|
||||
- SourceRoot id → just change the display label. The on-disk path
|
||||
is owned by the docker mount and never moves.
|
||||
- Folder id → rename the directory on disk AND update every
|
||||
descendant Folder.path + Photo.filepath that
|
||||
lived under the old prefix. Refuses to rename
|
||||
the source-root folder itself (= the row that
|
||||
matches the SourceRoot.path) because that would
|
||||
require renaming the docker mount.
|
||||
"""
|
||||
name = _validate_folder_name(body.name)
|
||||
|
||||
# Try SourceRoot first (display-only rename).
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
if source_root:
|
||||
source_root.name = name
|
||||
await db.commit()
|
||||
return {
|
||||
"id": source_root.id,
|
||||
"name": source_root.name,
|
||||
"path": source_root.path,
|
||||
}
|
||||
|
||||
# Otherwise it's a Folder row.
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
# Refuse to rename the bare source root mount through here.
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot rename the source root mount; rename the docker mount instead.",
|
||||
)
|
||||
|
||||
old_path = os.path.normpath(folder.path).rstrip(os.sep)
|
||||
parent_dir = os.path.dirname(old_path)
|
||||
new_path = os.path.join(parent_dir, name)
|
||||
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
if is_nextcloud_path(old_path):
|
||||
# WebDAV MOVE keeps Nextcloud's oc_filecache + sharing metadata
|
||||
# consistent. NC's MOVE is recursive — descendants come along,
|
||||
# exactly like shutil.move.
|
||||
nc_move(current_user, old_path, new_path)
|
||||
else:
|
||||
try:
|
||||
shutil.move(old_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
|
||||
# Update folder paths: this row + every descendant. SQLite REPLACE
|
||||
# rewrites the prefix; we use the trailing separator on the LIKE
|
||||
# pattern so a folder named "foo" doesn't accidentally match "foobar".
|
||||
await db.execute(
|
||||
sql_update(Folder)
|
||||
.where(Folder.id == folder.id)
|
||||
.values(path=new_path, name=name)
|
||||
)
|
||||
descendant_prefix = old_path + os.sep
|
||||
descendants = await db.execute(
|
||||
select(Folder).where(Folder.path.like(descendant_prefix + '%'))
|
||||
)
|
||||
for d in descendants.scalars().all():
|
||||
d.path = new_path + d.path[len(old_path):]
|
||||
|
||||
# Update every photo whose filepath lives under the old prefix.
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(Photo.filepath.like(descendant_prefix + '%'))
|
||||
)
|
||||
for p in photos_result.scalars().all():
|
||||
p.filepath = new_path + p.filepath[len(old_path):]
|
||||
# Photos directly inside this folder (not in a subdir) won't match
|
||||
# the descendant_prefix LIKE if their old path was old_path + '/file'
|
||||
# — actually they DO match, since 'oldpath/file' starts with
|
||||
# 'oldpath/'. So the loop above already covers them.
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"id": folder.id,
|
||||
"name": folder.name,
|
||||
"path": folder.path,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Create a new sub-folder under an existing Folder. Mirrors the
|
||||
create to disk so the next scan sees it. Body: { name, parent_id }.
|
||||
parent_id MUST be an existing Folder row id (any descendant of a
|
||||
source root); creating a brand-new top-level mount is a docker
|
||||
operation, not a UI one.
|
||||
"""
|
||||
name = _validate_folder_name(body.name)
|
||||
|
||||
parent = await get_user_folder(body.parent_id, current_user, db)
|
||||
|
||||
new_path = os.path.join(parent.path, name)
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
if is_nextcloud_path(new_path):
|
||||
# MKCOL via WebDAV so Nextcloud knows about the new collection.
|
||||
mkcol_for_user(current_user, new_path)
|
||||
else:
|
||||
try:
|
||||
os.makedirs(new_path, exist_ok=False)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
||||
|
||||
new_folder = Folder(
|
||||
name=name,
|
||||
path=new_path,
|
||||
source_root_id=parent.source_root_id,
|
||||
user_id=current_user.id,
|
||||
photo_count=0,
|
||||
)
|
||||
db.add(new_folder)
|
||||
await db.commit()
|
||||
await db.refresh(new_folder)
|
||||
return {
|
||||
"id": new_folder.id,
|
||||
"name": new_folder.name,
|
||||
"path": new_folder.path,
|
||||
"parent_id": parent.id,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{folder_id}")
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
mode: Literal['discard', 'permanent'] = Query('discard'),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a folder. Behavior depends on mode:
|
||||
|
||||
- mode=discard (default): mark every photo whose filepath lives
|
||||
under this folder as is_discarded=true. The folder row, its
|
||||
descendant rows, and the on-disk directory are LEFT INTACT —
|
||||
the user can still recover photos from the discard pile, and
|
||||
a re-scan won't double-import them.
|
||||
|
||||
- mode=permanent: unlink every photo file under this folder,
|
||||
remove the photo + folder rows from the DB, and rmtree the
|
||||
on-disk directory. Irreversible.
|
||||
|
||||
Refuses to delete the bare source-root mount in either mode (deleting
|
||||
the docker mount through the UI would be a footgun).
|
||||
"""
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete the source root mount through the UI",
|
||||
)
|
||||
|
||||
folder_path = os.path.normpath(folder.path).rstrip(os.sep)
|
||||
descendant_prefix = folder_path + os.sep
|
||||
|
||||
# Collect every photo under this folder OR any descendant. We match
|
||||
# by filepath prefix instead of folder_id because that catches photos
|
||||
# in nested subfolders without a recursive folder walk.
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(
|
||||
(Photo.filepath == folder_path)
|
||||
| (Photo.filepath.like(descendant_prefix + '%'))
|
||||
)
|
||||
)
|
||||
photos = photos_result.scalars().all()
|
||||
|
||||
if mode == 'discard':
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow()
|
||||
for p in photos:
|
||||
p.is_discarded = True
|
||||
p.discarded_at = now
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": "discard",
|
||||
"discarded": len(photos),
|
||||
}
|
||||
|
||||
# mode == 'permanent'
|
||||
file_errors = 0
|
||||
folder_is_nc = is_nextcloud_path(folder_path)
|
||||
|
||||
if folder_is_nc:
|
||||
# One WebDAV DELETE on the folder itself does the recursive
|
||||
# delete (NC moves the whole tree to trashbin and updates
|
||||
# oc_filecache for everything inside). Skip per-photo unlinks.
|
||||
try:
|
||||
nc_delete(current_user, folder_path)
|
||||
except HTTPException as e:
|
||||
logger.error(f"Nextcloud DELETE failed for {folder_path}: {e.detail}")
|
||||
raise
|
||||
for p in photos:
|
||||
await db.delete(p)
|
||||
else:
|
||||
for p in photos:
|
||||
try:
|
||||
if p.filepath and os.path.exists(p.filepath):
|
||||
os.unlink(p.filepath)
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
||||
await db.delete(p)
|
||||
|
||||
# Delete this folder + every descendant Folder row.
|
||||
await db.execute(
|
||||
sql_delete(Folder).where(
|
||||
(Folder.id == folder.id)
|
||||
| (Folder.path.like(descendant_prefix + '%'))
|
||||
)
|
||||
)
|
||||
|
||||
if not folder_is_nc:
|
||||
try:
|
||||
if os.path.isdir(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||
# Don't raise — DB rows are already gone, leaving an orphan
|
||||
# directory is the lesser evil.
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": "permanent",
|
||||
"deleted_photos": len(photos),
|
||||
"file_errors": file_errors,
|
||||
}
|
||||
|
||||
|
||||
async def _recompute_photo_hidden_flags(db: AsyncSession) -> None:
|
||||
"""Rematerialize photos.is_hidden from the full folder ancestry.
|
||||
|
||||
`photos.is_hidden` is true iff any ancestor folder in the photo's
|
||||
folder chain (including the folder the photo is directly in) has
|
||||
`folders.is_hidden = true`. Rather than do a recursive walk in
|
||||
Python, we lean on Postgres's WITH RECURSIVE to compute each
|
||||
folder's effective hidden state in a single query, then join on
|
||||
photos to bulk-update the flag.
|
||||
|
||||
Called after any folders.is_hidden toggle AND after moving photos
|
||||
between folders, since the photo's effective-hidden state can
|
||||
change even when no folder flag changes. Cheap — one O(folders)
|
||||
CTE + one O(photos) UPDATE. On a 13k-photo library this runs in
|
||||
under 50ms.
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
await db.execute(
|
||||
_text("""
|
||||
WITH RECURSIVE folder_chain AS (
|
||||
-- Base: source-root folders (no parent_id). Their own
|
||||
-- is_hidden is the starting effective value.
|
||||
SELECT id, is_hidden AS effective_hidden
|
||||
FROM folders
|
||||
WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
-- Step: a child folder inherits from its parent. The
|
||||
-- effective flag is true if the parent's effective flag
|
||||
-- is true OR the child's own flag is true. Short-circuit
|
||||
-- would be nice but a plain OR does the job.
|
||||
SELECT f.id, (f.is_hidden OR fc.effective_hidden) AS effective_hidden
|
||||
FROM folders f
|
||||
JOIN folder_chain fc ON f.parent_id = fc.id
|
||||
)
|
||||
UPDATE photos p
|
||||
SET is_hidden = fc.effective_hidden
|
||||
FROM folder_chain fc
|
||||
WHERE p.folder_id = fc.id
|
||||
AND p.is_hidden IS DISTINCT FROM fc.effective_hidden
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{folder_id}/hide")
|
||||
async def set_folder_hidden(
|
||||
folder_id: str,
|
||||
body: FolderHide,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Toggle the "hide from views" flag on a folder or source root.
|
||||
|
||||
A hidden folder's photos are excluded from every cross-cutting view
|
||||
(All Photos, Map, Tags, People, Search, sidebar counts, duplicates)
|
||||
but remain fully indexed and visible when the user navigates
|
||||
directly into the folder. The flag cascades to every descendant
|
||||
folder via the photos.is_hidden recompute — the child folder's own
|
||||
`is_hidden` column stays where the user set it, but a photo under a
|
||||
hidden ancestor will still be marked hidden.
|
||||
|
||||
Accepts both Folder ids and SourceRoot ids. For a SourceRoot, we
|
||||
look up the root Folder row (the one matching source_root.path) and
|
||||
flip that — source roots themselves don't carry the column because
|
||||
the whole subtree lives on a single Folder row anyway.
|
||||
"""
|
||||
# SourceRoot path — resolve to the Folder row at the mount point.
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
|
||||
folder: Optional[Folder]
|
||||
if source_root:
|
||||
root_folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.source_root_id == source_root.id,
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.path == os.path.normpath(source_root.path),
|
||||
)
|
||||
)
|
||||
folder = root_folder_result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Source root has no indexed Folder row yet; scan first.",
|
||||
)
|
||||
else:
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
folder.is_hidden = bool(body.hidden)
|
||||
await db.flush()
|
||||
|
||||
# Rematerialize photos.is_hidden across the whole tree. Cheap
|
||||
# enough (tens of ms on a typical library) that we don't need to
|
||||
# scope the update to just this folder's subtree — doing it
|
||||
# globally also fixes any drift introduced by earlier moves.
|
||||
await _recompute_photo_hidden_flags(db)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": folder.id,
|
||||
"name": folder.name,
|
||||
"path": folder.path,
|
||||
"is_hidden": folder.is_hidden,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{folder_id}/scan")
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Trigger manual re-scan of source root folder"""
|
||||
from app.tasks.celery import celery_app
|
||||
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id))
|
||||
source_root = result.scalar_one_or_none()
|
||||
|
||||
if not source_root:
|
||||
raise HTTPException(status_code=404, detail="Source folder not found")
|
||||
|
||||
# Queue scan task using the task name defined in the decorator
|
||||
task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
|
||||
return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id}
|
||||
@@ -1,448 +0,0 @@
|
||||
"""
|
||||
Heaps API router
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
from typing import Optional, Literal
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update, insert, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Heap, Photo, Folder
|
||||
from app.models.folders import SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_heap, get_user_or_shared_heap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────
|
||||
|
||||
class HeapCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class HeapUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class HeapPhotosBody(BaseModel):
|
||||
photo_ids: list[str]
|
||||
|
||||
|
||||
class HeapConvertBody(BaseModel):
|
||||
target_id: str # folder id OR source root id
|
||||
mode: Literal['move', 'copy'] = 'move'
|
||||
delete_heap: bool = False
|
||||
# Optional subfolder name to create inside the target. If provided, the
|
||||
# actual destination is target_dir/subfolder_name (created if missing).
|
||||
# Path separators and dot-segments are rejected.
|
||||
subfolder_name: Optional[str] = None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_heaps(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all heaps with photo counts."""
|
||||
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
||||
count_subq = (
|
||||
select(
|
||||
heap_photos.c.heap_id,
|
||||
func.count(heap_photos.c.photo_id).label("photo_count"),
|
||||
)
|
||||
.group_by(heap_photos.c.heap_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(Heap, count_subq.c.photo_count)
|
||||
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
||||
.where(Heap.user_id == current_user.id)
|
||||
.order_by(Heap.created_at.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"name": h.name,
|
||||
"is_active": bool(h.is_active),
|
||||
"created_at": h.created_at,
|
||||
"updated_at": h.updated_at,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for h, count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_heap(
|
||||
body: HeapCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new heap."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap = Heap(name=name, user_id=current_user.id)
|
||||
db.add(heap)
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
return {
|
||||
"id": heap.id,
|
||||
"name": heap.name,
|
||||
"is_active": bool(heap.is_active),
|
||||
"created_at": heap.created_at,
|
||||
"updated_at": heap.updated_at,
|
||||
"photo_count": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{heap_id}")
|
||||
async def update_heap(
|
||||
heap_id: str,
|
||||
body: HeapUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename a heap and/or toggle active state. Setting is_active=true on
|
||||
one heap deactivates all others (single-active invariant)."""
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap.name = name
|
||||
|
||||
if body.is_active is not None:
|
||||
if body.is_active:
|
||||
# Clear active flag on all other heaps for this user
|
||||
await db.execute(
|
||||
update(Heap)
|
||||
.where(Heap.user_id == current_user.id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
heap.is_active = True
|
||||
else:
|
||||
heap.is_active = False
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
return {
|
||||
"id": heap.id,
|
||||
"name": heap.name,
|
||||
"is_active": bool(heap.is_active),
|
||||
"created_at": heap.created_at,
|
||||
"updated_at": heap.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{heap_id}/duplicate", status_code=201)
|
||||
async def duplicate_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new heap with the same membership as an existing one. The
|
||||
new heap is named "{original} (copy)" and is never the active target —
|
||||
duplicating shouldn't quietly steal the user's T-key destination.
|
||||
"""
|
||||
source = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
new_heap = Heap(name=f"{source.name} (copy)", is_active=False, user_id=current_user.id)
|
||||
db.add(new_heap)
|
||||
await db.flush() # populate new_heap.id without committing yet
|
||||
|
||||
# Bulk-copy the membership rows. SELECT photo_id FROM heap_photos WHERE
|
||||
# heap_id = :src — INSERT each into the new heap. Done as a single
|
||||
# INSERT...SELECT to avoid round-tripping ids through Python.
|
||||
member_rows = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
photo_ids = [row[0] for row in member_rows.all()]
|
||||
if photo_ids:
|
||||
await db.execute(
|
||||
insert(heap_photos),
|
||||
[{"heap_id": new_heap.id, "photo_id": pid} for pid in photo_ids],
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_heap)
|
||||
return {
|
||||
"id": new_heap.id,
|
||||
"name": new_heap.name,
|
||||
"is_active": False,
|
||||
"photo_count": len(photo_ids),
|
||||
"created_at": new_heap.created_at,
|
||||
"updated_at": new_heap.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{heap_id}", status_code=204)
|
||||
async def delete_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||
rows in heap_photos cascade-delete."""
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
await db.delete(heap)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{heap_id}/photo_ids")
|
||||
async def get_heap_photo_ids(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return just the photo ids belonging to a heap. Used by the frontend
|
||||
to maintain a fast client-side membership lookup for the active heap
|
||||
(for the basket affordance on thumbnails) without fetching full photo
|
||||
records."""
|
||||
await get_user_or_shared_heap(heap_id, current_user, db)
|
||||
result = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
@router.post("/{heap_id}/photos")
|
||||
async def add_photos_to_heap(
|
||||
heap_id: str,
|
||||
body: HeapPhotosBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add photos to a heap. Idempotent: re-adding existing members is a
|
||||
no-op (handled by an INSERT OR IGNORE-style filter on duplicates).
|
||||
Shared users with write permission can add their own photos."""
|
||||
_heap, permission = await get_user_or_shared_heap(heap_id, current_user, db)
|
||||
if permission == "read":
|
||||
raise HTTPException(status_code=403, detail="Read-only access to this heap")
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "added": 0}
|
||||
|
||||
# Find which ids are already members so we don't violate the PK.
|
||||
existing = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
heap_photos.c.photo_id.in_(body.photo_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing.all()}
|
||||
new_ids = [pid for pid in body.photo_ids if pid not in existing_ids]
|
||||
|
||||
if new_ids:
|
||||
await db.execute(
|
||||
insert(heap_photos),
|
||||
[{"heap_id": heap_id, "photo_id": pid} for pid in new_ids],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
|
||||
|
||||
|
||||
@router.post("/{heap_id}/convert")
|
||||
async def convert_heap_to_folder(
|
||||
heap_id: str,
|
||||
body: HeapConvertBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert a heap into a folder by moving (or copying) every member
|
||||
photo into the target directory. Optionally deletes the heap row at
|
||||
the end.
|
||||
|
||||
target_id may be a Folder id or a SourceRoot id (matches the
|
||||
/photos/move convention so the same dropdown can populate it).
|
||||
"""
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
# Resolve target_id → (target_dir, target_folder)
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == body.target_id)
|
||||
)
|
||||
source_root = sr_check.scalar_one_or_none()
|
||||
|
||||
if source_root is not None:
|
||||
parent_dir = source_root.path
|
||||
parent_source_root_id = source_root.id
|
||||
else:
|
||||
folder_check = await db.execute(
|
||||
select(Folder).where(Folder.id == body.target_id)
|
||||
)
|
||||
parent_folder = folder_check.scalar_one_or_none()
|
||||
if parent_folder is None:
|
||||
raise HTTPException(status_code=404, detail="Target folder not found")
|
||||
parent_dir = parent_folder.path
|
||||
parent_source_root_id = parent_folder.source_root_id
|
||||
|
||||
if not os.path.isdir(parent_dir):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Target parent does not exist: {parent_dir}",
|
||||
)
|
||||
|
||||
# Resolve target_dir, creating an optional subfolder if requested.
|
||||
if body.subfolder_name is not None:
|
||||
sub = body.subfolder_name.strip()
|
||||
if not sub:
|
||||
raise HTTPException(status_code=400, detail="Subfolder name cannot be empty")
|
||||
if '/' in sub or '\\' in sub or sub in ('.', '..'):
|
||||
raise HTTPException(status_code=400, detail="Invalid subfolder name")
|
||||
target_dir = os.path.join(parent_dir, sub)
|
||||
if not os.path.exists(target_dir):
|
||||
try:
|
||||
os.makedirs(target_dir)
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create subfolder: {e}",
|
||||
)
|
||||
elif not os.path.isdir(target_dir):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{target_dir} exists but is not a directory",
|
||||
)
|
||||
else:
|
||||
target_dir = parent_dir
|
||||
|
||||
# Ensure a Folder row for the target, reusing the scanner helper so
|
||||
# path normalization + dedupe stay consistent.
|
||||
from app.tasks.scan import get_or_create_folder
|
||||
target_folder = await get_or_create_folder(db, target_dir, parent_source_root_id)
|
||||
|
||||
# Fetch the heap's photos via the join table.
|
||||
photo_result = await db.execute(
|
||||
select(Photo)
|
||||
.join(heap_photos, Photo.id == heap_photos.c.photo_id)
|
||||
.where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
photos = photo_result.scalars().all()
|
||||
|
||||
moved = 0
|
||||
copied = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
|
||||
if not os.path.exists(os.path.join(directory, filename)):
|
||||
return filename
|
||||
stem, ext = os.path.splitext(filename)
|
||||
for i in range(1, 100):
|
||||
suffix = '' if i == 1 else f' {i}'
|
||||
candidate = f"{stem} (copy{suffix}){ext}"
|
||||
if not os.path.exists(os.path.join(directory, candidate)):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
for photo in photos:
|
||||
if not os.path.exists(photo.filepath):
|
||||
errors.append({"id": photo.id, "error": "source file missing"})
|
||||
continue
|
||||
|
||||
if body.mode == 'move':
|
||||
if photo.folder_id == target_folder.id:
|
||||
continue # already there
|
||||
new_path = os.path.join(target_dir, photo.filename)
|
||||
if os.path.exists(new_path):
|
||||
errors.append({"id": photo.id, "error": f"name collision: {photo.filename}"})
|
||||
continue
|
||||
try:
|
||||
shutil.move(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
errors.append({"id": photo.id, "error": str(e)})
|
||||
continue
|
||||
photo.filepath = new_path
|
||||
photo.folder_id = target_folder.id
|
||||
moved += 1
|
||||
else: # copy
|
||||
new_name = _unique_target_name(target_dir, photo.filename)
|
||||
if new_name is None:
|
||||
errors.append({"id": photo.id, "error": "too many name collisions"})
|
||||
continue
|
||||
new_path = os.path.join(target_dir, new_name)
|
||||
try:
|
||||
shutil.copy2(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
errors.append({"id": photo.id, "error": str(e)})
|
||||
continue
|
||||
new_photo = Photo(
|
||||
filepath=new_path,
|
||||
filename=new_name,
|
||||
folder_id=target_folder.id,
|
||||
file_hash=photo.file_hash,
|
||||
media_type=photo.media_type,
|
||||
original_format=photo.original_format,
|
||||
width=photo.width,
|
||||
height=photo.height,
|
||||
file_size=photo.file_size,
|
||||
taken_at=photo.taken_at,
|
||||
taken_at_source=photo.taken_at_source,
|
||||
user_title=photo.user_title,
|
||||
user_notes=photo.user_notes,
|
||||
rating=photo.rating,
|
||||
color_label=photo.color_label,
|
||||
exif_json=photo.exif_json,
|
||||
is_duplicate=True,
|
||||
processing_status='pending',
|
||||
)
|
||||
db.add(new_photo)
|
||||
copied += 1
|
||||
|
||||
if body.delete_heap:
|
||||
await db.delete(heap)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": body.mode,
|
||||
"moved": moved,
|
||||
"copied": copied,
|
||||
"errors": errors,
|
||||
"heap_deleted": body.delete_heap,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{heap_id}/photos")
|
||||
async def remove_photos_from_heap(
|
||||
heap_id: str,
|
||||
body: HeapPhotosBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove photos from a heap. Removing a non-member is a no-op.
|
||||
Shared users with write permission can remove photos."""
|
||||
_heap, permission = await get_user_or_shared_heap(heap_id, current_user, db)
|
||||
if permission == "read":
|
||||
raise HTTPException(status_code=403, detail="Read-only access to this heap")
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "removed": 0}
|
||||
|
||||
res = await db.execute(
|
||||
delete(heap_photos).where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
heap_photos.c.photo_id.in_(body.photo_ids),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "success", "removed": res.rowcount or 0}
|
||||
@@ -1,879 +0,0 @@
|
||||
"""
|
||||
Library API router for stats, scanning, and maintenance.
|
||||
|
||||
The /maintenance/* endpoints are surfaced through the frontend Settings
|
||||
panel. They're intentionally idempotent and operate by re-queueing the
|
||||
existing Celery tasks rather than doing any heavy lifting in the
|
||||
request thread.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func, update, or_, true as sa_true
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.models.folders import SourceRoot, Folder
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owner_filter(user: User, scope: str | None):
|
||||
"""Return a column expression scoping photos to the current user,
|
||||
or a pass-through true() when an admin requests global scope."""
|
||||
if scope == "global" and user.role == "admin":
|
||||
return sa_true()
|
||||
return Photo.user_id == user.id
|
||||
|
||||
|
||||
async def _active_source_root_paths(
|
||||
db: AsyncSession, user: User, scope: str | None
|
||||
) -> list[str]:
|
||||
"""Active SourceRoot.path values visible to this user, honouring the
|
||||
admin ?scope=global escape hatch."""
|
||||
q = select(SourceRoot.path).where(SourceRoot.is_active.is_(True))
|
||||
if not (scope == "global" and user.role == "admin"):
|
||||
q = q.where(SourceRoot.user_id == user.id)
|
||||
return (await db.execute(q)).scalars().all()
|
||||
|
||||
# Media types we accept in the regenerate-thumbnails request body. Mirrors
|
||||
# the values produced by `app.tasks.scan.get_media_type`.
|
||||
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_library_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Get library statistics. Pass ?scope=global (admin only) for
|
||||
cross-user totals (used by the Settings page)."""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
visible = owner & (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False))
|
||||
|
||||
all_photos_count = (
|
||||
await db.execute(select(func.count(Photo.id)).where(visible))
|
||||
).scalar() or 0
|
||||
|
||||
rated_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(visible, Photo.rating >= 1)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
colored_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
visible, Photo.color_label.is_not(None)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
with_gps_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
visible, Photo.latitude.is_not(None)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
duplicates_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
visible, Photo.is_duplicate.is_(True)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
discarded_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
owner, Photo.is_hidden.is_(False), Photo.is_discarded.is_(True)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Legacy split (kept for the existing /stats consumers).
|
||||
photo_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
owner,
|
||||
Photo.media_type.in_(['photo', 'heic', 'raw'])
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
video_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(owner, Photo.media_type == 'video')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
size = (await db.execute(select(func.sum(Photo.file_size)).where(owner))).scalar() or 0
|
||||
|
||||
roots = sorted(await _active_source_root_paths(db, current_user, scope))
|
||||
|
||||
return {
|
||||
"all_photos": all_photos_count,
|
||||
"rated": rated_count,
|
||||
"colored": colored_count,
|
||||
"with_gps": with_gps_count,
|
||||
"duplicates": duplicates_count,
|
||||
"discarded": discarded_count,
|
||||
"total_photos": photo_count,
|
||||
"total_videos": video_count,
|
||||
"total_size": size,
|
||||
"total_size_gb": round(size / (1024**3), 2) if size else 0,
|
||||
"source_dirs": roots,
|
||||
}
|
||||
|
||||
@router.post("/scan")
|
||||
async def trigger_scan(current_user: User = Depends(get_current_user)):
|
||||
"""Trigger full library re-scan"""
|
||||
from app.tasks.scan import scan_all_source_roots
|
||||
|
||||
scan_all_source_roots.delay()
|
||||
|
||||
return {"status": "success", "message": "Library scan started"}
|
||||
|
||||
|
||||
@router.post("/maintenance/recover-stuck")
|
||||
async def recover_stuck_photos(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Reset photos stuck in 'processing' for more than 30 minutes back to
|
||||
'pending' so the pipeline can retry them. Returns the count of recovered
|
||||
photos."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=30)
|
||||
result = await db.execute(
|
||||
update(Photo)
|
||||
.where(
|
||||
Photo.processing_status == 'processing',
|
||||
Photo.updated_at < cutoff,
|
||||
)
|
||||
.values(
|
||||
processing_status='pending',
|
||||
processing_error='Auto-recovered from stuck processing state',
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
count = result.rowcount
|
||||
if count:
|
||||
logger.info("Recovered %d stuck photos back to pending", count)
|
||||
return {"status": "success", "recovered": count}
|
||||
|
||||
|
||||
@router.post("/backfill-gps")
|
||||
async def trigger_backfill_gps(current_user: User = Depends(get_current_user)):
|
||||
"""Re-run EXIF metadata extraction on every photo that's still missing
|
||||
GPS coordinates. Useful after fixing the EXIF parser, or any time the
|
||||
Map view looks emptier than expected. Returns immediately — work runs
|
||||
on the Celery worker."""
|
||||
from app.tasks.scan import backfill_gps
|
||||
|
||||
backfill_gps.delay()
|
||||
return {"status": "success", "message": "GPS backfill queued"}
|
||||
|
||||
|
||||
@router.post("/maintenance/backfill-taken-at")
|
||||
async def trigger_backfill_taken_at(current_user: User = Depends(get_current_user)):
|
||||
"""Re-run extract_metadata on every non-manual photo to recompute
|
||||
taken_at with the current EXIF-priority list and path-based fallback.
|
||||
Useful after the date-extraction logic changes (e.g. dropping the
|
||||
ModifyDate fallback). Manual edits are preserved."""
|
||||
from app.services.metadata import backfill_taken_at
|
||||
|
||||
backfill_taken_at.delay()
|
||||
return {"status": "success", "message": "taken_at backfill queued"}
|
||||
|
||||
|
||||
@router.get("/scan/status")
|
||||
async def get_scan_status(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Get current scan status"""
|
||||
import redis
|
||||
from app.config import settings
|
||||
|
||||
# Connect to Redis to get scan status
|
||||
r = redis.Redis.from_url(settings.redis_url)
|
||||
|
||||
# Get scan status from Redis (set by worker tasks)
|
||||
is_scanning = r.get('scan:active') == b'true'
|
||||
current_folder = r.get('scan:current_folder')
|
||||
processed_files = int(r.get('scan:processed_files') or 0)
|
||||
total_files = int(r.get('scan:total_files') or 0)
|
||||
errors = r.lrange('scan:errors', 0, -1)
|
||||
|
||||
return {
|
||||
"is_scanning": is_scanning,
|
||||
"current_folder": current_folder.decode() if current_folder else None,
|
||||
"processed_files": processed_files,
|
||||
"total_files": total_files,
|
||||
"errors": [e.decode() for e in errors] if errors else []
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maintenance endpoints — surfaced via the Settings panel.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RegenerateThumbnailsRequest(BaseModel):
|
||||
"""Optional filters narrowing which photos get re-queued. With both
|
||||
fields omitted the request resets every photo in the library."""
|
||||
media_types: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Restrict to these media_type values (photo/raw/heic/video).",
|
||||
)
|
||||
only_failed: bool = Field(
|
||||
default=False,
|
||||
description="If true, only re-queue photos whose processing_status is 'failed'.",
|
||||
)
|
||||
only_pending: bool = Field(
|
||||
default=False,
|
||||
description="If true, only (re-)queue photos whose processing_status is 'pending'. "
|
||||
"Useful for kicking rows that were created by a scan but never had "
|
||||
"their thumbnail task picked up.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/maintenance/thumbnail-stats")
|
||||
async def get_thumbnail_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Counts of photos by processing_status, plus a media-type breakdown
|
||||
so the Settings panel can show the user what's outstanding."""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
status_rows = (
|
||||
await db.execute(
|
||||
select(Photo.processing_status, func.count(Photo.id))
|
||||
.where(owner)
|
||||
.group_by(Photo.processing_status)
|
||||
)
|
||||
).all()
|
||||
|
||||
media_rows = (
|
||||
await db.execute(
|
||||
select(Photo.media_type, func.count(Photo.id))
|
||||
.where(owner)
|
||||
.group_by(Photo.media_type)
|
||||
)
|
||||
).all()
|
||||
|
||||
by_status = {status or 'unknown': count for status, count in status_rows}
|
||||
by_media_type = {media or 'unknown': count for media, count in media_rows}
|
||||
total = sum(by_status.values())
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": by_status.get('pending', 0),
|
||||
"processing": by_status.get('processing', 0),
|
||||
"completed": by_status.get('completed', 0),
|
||||
"failed": by_status.get('failed', 0),
|
||||
"by_media_type": by_media_type,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance/regenerate-thumbnails")
|
||||
async def regenerate_thumbnails(
|
||||
body: RegenerateThumbnailsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Reset matching photos' on-disk thumbnail directories and re-queue
|
||||
Celery thumbnail generation. Used by the Settings panel for the
|
||||
'regenerate video thumbnails' / 'regenerate failed' buttons.
|
||||
|
||||
Files on disk are removed under /data/thumbs/<photo_id>/ so the next
|
||||
request to /photos/{id}/thumb/{size} actually re-generates instead of
|
||||
serving the stale placeholder.
|
||||
"""
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
|
||||
owner = _owner_filter(current_user, scope)
|
||||
|
||||
# Validate media_types early so a typo can't silently match nothing.
|
||||
media_types = body.media_types
|
||||
if media_types is not None:
|
||||
invalid = [m for m in media_types if m not in _VALID_MEDIA_TYPES]
|
||||
if invalid:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Invalid media_types: {invalid}. "
|
||||
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
|
||||
}
|
||||
|
||||
query = select(Photo).where(owner)
|
||||
if media_types:
|
||||
query = query.where(Photo.media_type.in_(media_types))
|
||||
if body.only_failed:
|
||||
query = query.where(Photo.processing_status == 'failed')
|
||||
if body.only_pending:
|
||||
query = query.where(Photo.processing_status == 'pending')
|
||||
|
||||
photos = (await db.execute(query)).scalars().all()
|
||||
|
||||
cleared_dirs = 0
|
||||
file_errors = 0
|
||||
for photo in photos:
|
||||
thumb_dir = f"/data/thumbs/{photo.id}"
|
||||
if os.path.isdir(thumb_dir):
|
||||
try:
|
||||
shutil.rmtree(thumb_dir)
|
||||
cleared_dirs += 1
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.warning(f"Could not clear thumb dir {thumb_dir}: {e}")
|
||||
photo.processing_status = 'pending'
|
||||
photo.processing_error = None
|
||||
photo.thumb_small = None
|
||||
photo.thumb_medium = None
|
||||
photo.thumb_large = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Queue celery tasks AFTER the commit so the worker sees the reset
|
||||
# state when it picks the job up.
|
||||
queued = 0
|
||||
for photo in photos:
|
||||
try:
|
||||
generate_thumbnails.delay(photo.id)
|
||||
queued += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not queue thumbnail job for {photo.id}: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"matched": len(photos),
|
||||
"queued": queued,
|
||||
"cleared_dirs": cleared_dirs,
|
||||
"file_errors": file_errors,
|
||||
"filters": {
|
||||
"media_types": media_types,
|
||||
"only_failed": body.only_failed,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/worker-status")
|
||||
async def get_worker_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Diagnostics for the Celery worker fleet + recent task failures.
|
||||
|
||||
Surfaced in the Settings panel so the user can spot a stuck queue or
|
||||
a worker that's gone away without tailing container logs. Returns:
|
||||
|
||||
- workers: list of {name, status, active, concurrency, queues}
|
||||
derived from celery_app.control.inspect(). `status` is 'online'
|
||||
when ping succeeds, 'unreachable' otherwise. Empty list means no
|
||||
workers are responding at all (broker down, container crashed,
|
||||
wrong queue routing, etc.).
|
||||
- queues: per-queue depth read from Redis (LLEN of each queue key
|
||||
used by celery.kombu). Mirrors what tasks are waiting to be
|
||||
picked up.
|
||||
- failures: aggregate count of photos with processing_status='failed'
|
||||
plus the most recent N error messages so the user can see *why*
|
||||
things failed without opening the DB.
|
||||
- broker_ok: bool — could we even reach Redis?
|
||||
"""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
from app.tasks.celery import celery_app
|
||||
from app.config import settings
|
||||
import redis as _redis
|
||||
|
||||
# ----- Celery inspect (workers + active tasks) -------------------------
|
||||
# Each inspect.* call is a separate broadcast-and-wait with its own
|
||||
# timeout, so running them serially multiplies the wait. Fan them out
|
||||
# to threads and gather, collapsing 6 × timeout into ~1 × timeout.
|
||||
# Timeout dropped to 0.5s — a responsive worker answers within a few
|
||||
# ms; anything past that is effectively "not responding" for the
|
||||
# purposes of a settings dashboard.
|
||||
import asyncio
|
||||
workers: list[dict] = []
|
||||
inspect_error: Optional[str] = None
|
||||
try:
|
||||
inspect = celery_app.control.inspect(timeout=0.5)
|
||||
ping, active, reserved, scheduled, stats, active_queues = await asyncio.gather(
|
||||
asyncio.to_thread(inspect.ping),
|
||||
asyncio.to_thread(inspect.active),
|
||||
asyncio.to_thread(inspect.reserved),
|
||||
asyncio.to_thread(inspect.scheduled),
|
||||
asyncio.to_thread(inspect.stats),
|
||||
asyncio.to_thread(inspect.active_queues),
|
||||
)
|
||||
ping = ping or {}
|
||||
active = active or {}
|
||||
reserved = reserved or {}
|
||||
scheduled = scheduled or {}
|
||||
stats = stats or {}
|
||||
active_queues = active_queues or {}
|
||||
|
||||
worker_names = set(ping) | set(active) | set(stats)
|
||||
for name in sorted(worker_names):
|
||||
wstats = stats.get(name) or {}
|
||||
pool = wstats.get('pool') or {}
|
||||
workers.append({
|
||||
"name": name,
|
||||
"status": "online" if name in ping else "unreachable",
|
||||
"active": len(active.get(name, []) or []),
|
||||
"reserved": len(reserved.get(name, []) or []),
|
||||
"scheduled": len(scheduled.get(name, []) or []),
|
||||
"concurrency": pool.get('max-concurrency'),
|
||||
"processed": (wstats.get('total') or {}),
|
||||
"queues": [q.get('name') for q in (active_queues.get(name) or [])],
|
||||
"active_tasks": [
|
||||
{
|
||||
"id": t.get('id'),
|
||||
"name": t.get('name'),
|
||||
"args": t.get('args'),
|
||||
"time_start": t.get('time_start'),
|
||||
}
|
||||
for t in (active.get(name) or [])[:10]
|
||||
],
|
||||
})
|
||||
except Exception as e:
|
||||
inspect_error = str(e)
|
||||
logger.warning(f"Celery inspect failed: {e}")
|
||||
|
||||
# ----- Broker / queue depth --------------------------------------------
|
||||
broker_ok = False
|
||||
queue_depths: dict[str, int] = {}
|
||||
broker_error: Optional[str] = None
|
||||
try:
|
||||
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
|
||||
r.ping()
|
||||
broker_ok = True
|
||||
for q in ('default', 'high', 'low'):
|
||||
try:
|
||||
queue_depths[q] = int(r.llen(q) or 0)
|
||||
except Exception:
|
||||
queue_depths[q] = 0
|
||||
except Exception as e:
|
||||
broker_error = str(e)
|
||||
logger.warning(f"Redis broker unreachable: {e}")
|
||||
|
||||
# ----- Recent task failures from the photos table ----------------------
|
||||
failed_total = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(owner, Photo.processing_status == 'failed')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
recent_failed_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Photo.id,
|
||||
Photo.filename,
|
||||
Photo.media_type,
|
||||
Photo.processing_error,
|
||||
Photo.updated_at,
|
||||
)
|
||||
.where(owner, Photo.processing_status == 'failed')
|
||||
.order_by(Photo.updated_at.desc().nullslast())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
|
||||
recent_failures = [
|
||||
{
|
||||
"photo_id": row[0],
|
||||
"filename": row[1],
|
||||
"media_type": row[2],
|
||||
"error": (row[3] or '')[:500],
|
||||
"updated_at": row[4].isoformat() if row[4] else None,
|
||||
}
|
||||
for row in recent_failed_rows
|
||||
]
|
||||
|
||||
# ----- Most recent scan errors (Redis list) ----------------------------
|
||||
scan_errors: list[str] = []
|
||||
try:
|
||||
if broker_ok:
|
||||
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
|
||||
raw = r.lrange('scan:errors', 0, 19) or []
|
||||
scan_errors = [e.decode(errors='replace') for e in raw]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read scan:errors: {e}")
|
||||
|
||||
return {
|
||||
"broker_ok": broker_ok,
|
||||
"broker_error": broker_error,
|
||||
"inspect_error": inspect_error,
|
||||
"workers": workers,
|
||||
"worker_count": len(workers),
|
||||
"queues": queue_depths,
|
||||
"failures": {
|
||||
"total": failed_total,
|
||||
"recent": recent_failures,
|
||||
},
|
||||
"scan_errors": scan_errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/pipeline-stats")
|
||||
async def get_pipeline_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Per-stage progress across the ingestion pipeline.
|
||||
|
||||
Returns a `{stage_key: {done, total, label}}` map so the Settings
|
||||
panel can render one progress bar per stage. `total` is the number
|
||||
of non-discarded photos the stage is *expected* to run on — which is
|
||||
every non-discarded photo for most stages, or a narrower subset when
|
||||
a stage is image-only (e.g. embeddings don't run on videos).
|
||||
|
||||
Keep the shape flat + serialisable; the frontend turns it straight
|
||||
into a list of rows without needing to know about the models.
|
||||
"""
|
||||
from app.models.tags import photo_tags # association Table, not a model
|
||||
|
||||
owner = _owner_filter(current_user, scope)
|
||||
not_discarded = owner & Photo.is_discarded.is_(False)
|
||||
|
||||
async def scalar_count(query):
|
||||
return (await db.execute(query)).scalar() or 0
|
||||
|
||||
# Total non-discarded photos — the denominator for most stages.
|
||||
total_photos = await scalar_count(
|
||||
select(func.count(Photo.id)).where(not_discarded)
|
||||
)
|
||||
|
||||
# Image-only denominator (embeddings, tags, faces, OCR, phash). We
|
||||
# exclude videos because those stages either don't apply or run off
|
||||
# the extracted video frame which is treated separately.
|
||||
total_images = await scalar_count(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.media_type != 'video'
|
||||
)
|
||||
)
|
||||
|
||||
completed = await scalar_count(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.processing_status == 'completed'
|
||||
)
|
||||
)
|
||||
with_exif = await scalar_count(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.exif_json.is_not(None)
|
||||
)
|
||||
)
|
||||
with_gps = await scalar_count(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded,
|
||||
Photo.latitude.is_not(None),
|
||||
Photo.longitude.is_not(None),
|
||||
)
|
||||
)
|
||||
with_phash = await scalar_count(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.phash.is_not(None)
|
||||
)
|
||||
)
|
||||
|
||||
duplicate_groups = await scalar_count(
|
||||
select(func.count(func.distinct(Photo.duplicate_group_id))).where(
|
||||
not_discarded, Photo.duplicate_group_id.is_not(None)
|
||||
)
|
||||
)
|
||||
duplicate_members = await scalar_count(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.duplicate_group_id.is_not(None)
|
||||
)
|
||||
)
|
||||
|
||||
# Ordered list so the frontend renders stages in pipeline order
|
||||
# without needing to know the sequence itself.
|
||||
stages = [
|
||||
{
|
||||
"key": "thumbnails",
|
||||
"label": "Thumbnails & pHash",
|
||||
"done": completed,
|
||||
"total": total_photos,
|
||||
"hint": "Generated on scan. Unlocks every downstream stage.",
|
||||
},
|
||||
{
|
||||
"key": "exif",
|
||||
"label": "EXIF metadata",
|
||||
"done": with_exif,
|
||||
"total": total_photos,
|
||||
"hint": "Camera, lens, capture time. Required for GPS + taken_at.",
|
||||
},
|
||||
{
|
||||
"key": "gps",
|
||||
"label": "GPS coordinates",
|
||||
"done": with_gps,
|
||||
"total": total_photos,
|
||||
"hint": "Subset of EXIF. Drives the map view; many photos legitimately have none.",
|
||||
"partial": True, # not every photo is expected to have GPS
|
||||
},
|
||||
{
|
||||
"key": "phash",
|
||||
"label": "Perceptual hashes",
|
||||
"done": with_phash,
|
||||
"total": total_images,
|
||||
"hint": "Feeds duplicate detection.",
|
||||
},
|
||||
{
|
||||
"key": "duplicates",
|
||||
"label": "Duplicate groups",
|
||||
"done": duplicate_groups,
|
||||
"total": duplicate_groups, # same — current count, not a progress ratio
|
||||
"hint": f"{duplicate_members} photos in {duplicate_groups} groups. Run regroup_duplicates after new imports.",
|
||||
"standalone": True,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"total_photos": total_photos,
|
||||
"total_images": total_images,
|
||||
"stages": stages,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/missing-stats")
|
||||
async def get_missing_stats(current_user: User = Depends(get_current_user)):
|
||||
"""Count photos whose files no longer exist on disk under a mounted
|
||||
source root. Surfaced in Settings so the user can see a number before
|
||||
pulling the trigger on prune-missing. Cheap enough to call freely."""
|
||||
from app.services.cleanup import prune_missing_photos
|
||||
return await prune_missing_photos(dry_run=True)
|
||||
|
||||
|
||||
@router.post("/maintenance/prune-missing")
|
||||
async def run_prune_missing(current_user: User = Depends(get_current_user)):
|
||||
"""Actually delete the orphaned photo rows reported by /missing-stats.
|
||||
Common cause: PHOTO_DIRS in .env was repointed at a different library
|
||||
leaving every old row dangling. Skips any photo whose source root
|
||||
isn't currently mounted (almost always means an unmounted drive)."""
|
||||
from app.services.cleanup import prune_missing_photos
|
||||
try:
|
||||
return {"status": "success", **(await prune_missing_photos(dry_run=False))}
|
||||
except Exception as e:
|
||||
logger.error(f"Prune missing failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/maintenance/cleanup")
|
||||
async def run_data_integrity_cleanup(current_user: User = Depends(get_current_user)):
|
||||
"""Re-run the source-roots / folders / photos data-integrity cleanup
|
||||
that normally only runs on backend startup. Idempotent."""
|
||||
from app.services.cleanup import cleanup_data_integrity
|
||||
|
||||
try:
|
||||
await cleanup_data_integrity()
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
logger.error(f"Manual cleanup failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Duplicate detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/duplicates/groups")
|
||||
async def get_duplicate_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
scope: str | None = Query(None),
|
||||
):
|
||||
"""Return every duplicate group with its members.
|
||||
|
||||
Drives the frontend grouped grid view in the Duplicates section. One
|
||||
SQL query, bucketed in Python — no N+1, no per-member fetch. Groups
|
||||
are sorted by member_count DESC then earliest taken_at DESC so the
|
||||
biggest / most recent clusters bubble to the top.
|
||||
|
||||
Each group also carries a `reason` field:
|
||||
* "exact" — every member shares the same SHA-256 (true byte
|
||||
duplicates that the perceptual hash trivially caught)
|
||||
* "similar" — members differ at the byte level but match perceptually
|
||||
"""
|
||||
owner = _owner_filter(current_user, scope)
|
||||
|
||||
# Restrict to photos under an active SourceRoot in the user's
|
||||
# settings. Folder.source_root_id alone isn't trustworthy: Nextcloud's
|
||||
# "move to trash" flow leaves Folder rows like `…/files/.delete/
|
||||
# purge-1` still wired to the original source_root_id, leaking their
|
||||
# photos into this view as ghost paths the user never configured.
|
||||
active_root_paths = await _active_source_root_paths(db, current_user, scope)
|
||||
if not active_root_paths:
|
||||
return {"groups": [], "total_groups": 0, "total_members": 0}
|
||||
|
||||
folder_in_scope = or_(
|
||||
*[
|
||||
(Folder.path == p) | (Folder.path.like(p + '/%'))
|
||||
for p in active_root_paths
|
||||
]
|
||||
)
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Photo.id,
|
||||
Photo.filename,
|
||||
Photo.filepath,
|
||||
Photo.taken_at,
|
||||
Photo.file_size,
|
||||
Photo.width,
|
||||
Photo.height,
|
||||
Photo.thumb_small,
|
||||
Photo.file_hash,
|
||||
Photo.folder_id,
|
||||
Photo.media_type,
|
||||
Photo.duplicate_group_id,
|
||||
)
|
||||
.join(Folder, Folder.id == Photo.folder_id)
|
||||
.where(owner)
|
||||
.where(Photo.duplicate_group_id.is_not(None))
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
.where(Photo.is_hidden.is_(False))
|
||||
.where(folder_in_scope)
|
||||
.order_by(Photo.duplicate_group_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
# Bucket members by group_id. filepath is included so the
|
||||
# Duplicates view can show "which folder does this copy live in"
|
||||
# — the discriminator the user needs to pick a winner.
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
member = {
|
||||
"id": row[0],
|
||||
"filename": row[1],
|
||||
"filepath": row[2],
|
||||
"taken_at": row[3].isoformat() if row[3] else None,
|
||||
"file_size": row[4],
|
||||
"width": row[5],
|
||||
"height": row[6],
|
||||
"thumb_small": row[7],
|
||||
"file_hash": row[8],
|
||||
"folder_id": row[9],
|
||||
"media_type": row[10],
|
||||
}
|
||||
groups.setdefault(row[11], []).append(member)
|
||||
|
||||
def earliest(g: list[dict]) -> str:
|
||||
# Used as a secondary sort key. Photos with no taken_at sort last
|
||||
# by returning a far-future sentinel.
|
||||
taken = [m["taken_at"] for m in g if m["taken_at"]]
|
||||
return min(taken) if taken else "9999"
|
||||
|
||||
out = []
|
||||
for group_id, members in groups.items():
|
||||
if len(members) < 2:
|
||||
# Defensive: a regroup race could leave a singleton briefly.
|
||||
# Skip it so the UI never shows a "group of 1".
|
||||
continue
|
||||
# exact iff every member shares the same non-null file_hash
|
||||
# (true byte-identical copies that pHash also caught). Anything
|
||||
# else — different hashes, missing hashes — counts as "similar".
|
||||
all_hashes = [m["file_hash"] for m in members]
|
||||
reason = (
|
||||
"exact"
|
||||
if len(set(all_hashes)) == 1 and all_hashes[0] is not None
|
||||
else "similar"
|
||||
)
|
||||
out.append({
|
||||
"group_id": group_id,
|
||||
"member_count": len(members),
|
||||
"reason": reason,
|
||||
"members": members,
|
||||
})
|
||||
|
||||
out.sort(key=lambda g: (-g["member_count"], earliest(g["members"])))
|
||||
return {
|
||||
"groups": out,
|
||||
"total_groups": len(out),
|
||||
"total_members": sum(g["member_count"] for g in out),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance/regroup-duplicates")
|
||||
async def trigger_regroup_duplicates(current_user: User = Depends(get_current_user)):
|
||||
"""Recompute duplicate groups from current perceptual hashes.
|
||||
|
||||
Fires the celery `regroup_duplicates` task which walks every photo's
|
||||
phash, clusters by Hamming distance, and rewrites duplicate_group_id /
|
||||
is_duplicate columns. Idempotent."""
|
||||
from app.tasks.thumbs import regroup_duplicates_task
|
||||
try:
|
||||
regroup_duplicates_task.delay()
|
||||
return {"status": "queued"}
|
||||
except Exception as e:
|
||||
logger.error(f"Regroup queue failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/maintenance/backfill-phashes")
|
||||
async def trigger_backfill_phashes(current_user: User = Depends(get_current_user)):
|
||||
"""Compute perceptual hashes for every photo currently missing one.
|
||||
|
||||
One-shot recovery path for libraries that existed before the phash
|
||||
column was added — the thumbs worker computes phash for everything
|
||||
new, but old rows need a backfill pass."""
|
||||
from app.tasks.thumbs import backfill_phashes
|
||||
try:
|
||||
backfill_phashes.delay()
|
||||
return {"status": "queued"}
|
||||
except Exception as e:
|
||||
logger.error(f"Backfill queue failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/maintenance/backfill-video-cache")
|
||||
async def trigger_backfill_video_cache(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Pre-transcode every active video in the library so /playback hits
|
||||
the cache on first user click instead of paying the encode cost
|
||||
inline. Idempotent — pretranscode_video skips photos whose cache is
|
||||
already populated and current. CPU-bound; runs on the low-priority
|
||||
queue so it doesn't fight thumbnails or other user-facing tasks."""
|
||||
from app.tasks.video import pretranscode_video
|
||||
result = await db.execute(
|
||||
select(Photo.id, Photo.filepath).where(
|
||||
Photo.media_type == 'video',
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
queued = 0
|
||||
for photo_id, filepath in rows:
|
||||
try:
|
||||
pretranscode_video.delay(photo_id, filepath)
|
||||
queued += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"failed to queue pretranscode for {photo_id}: {e}")
|
||||
return {"status": "queued", "count": queued}
|
||||
|
||||
|
||||
@router.post("/maintenance/start-watcher")
|
||||
async def start_file_watcher(current_user: User = Depends(get_current_user)):
|
||||
"""Start the filesystem watcher. Uses a Redis lock so only one
|
||||
instance runs at a time — safe to call repeatedly."""
|
||||
from app.tasks.scan import watch_folders
|
||||
try:
|
||||
watch_folders.apply_async(countdown=2)
|
||||
return {"status": "queued"}
|
||||
except Exception as e:
|
||||
logger.error(f"Watcher queue failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Internal webhook receiver for Nextcloud file events.
|
||||
|
||||
Replaces the watchfiles-based `watch_folders` Celery task: instead of
|
||||
mule polling the bind-mount with inotify, Nextcloud's `webhook_listeners`
|
||||
app POSTs here on every NodeCreated / NodeWritten / NodeDeleted /
|
||||
NodeRenamed event, and we dispatch the same scan_folder /
|
||||
handle_file_deletion machinery that the watcher used.
|
||||
|
||||
Auth: `Authorization: Bearer <NEXTCLOUD_WEBHOOK_SECRET>` header.
|
||||
constant_time compare. 401 on mismatch, 401 also when the secret isn't
|
||||
configured (fail closed).
|
||||
|
||||
The route is intentionally outside `/api/v1/photos/...` so it doesn't
|
||||
get caught by the per-user auth middleware — webhook requests come
|
||||
from Nextcloud as a service principal, not as a logged-in user. They
|
||||
get NO mule app session.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.folders import SourceRoot
|
||||
from app.services.nextcloud_dav import NEXTCLOUD_USERS_ROOT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Same list the on-disk watcher used (app.tasks.scan.SUPPORTED_EXTENSIONS).
|
||||
# Imported lazily inside the handler so this module can load without
|
||||
# pulling in the tasks package at startup.
|
||||
def _supported_extensions() -> set[str]:
|
||||
from app.tasks.scan import SUPPORTED_EXTENSIONS
|
||||
return SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
def _expected_secret() -> str | None:
|
||||
return os.environ.get("NEXTCLOUD_WEBHOOK_SECRET") or None
|
||||
|
||||
|
||||
def _nc_path_to_abs(nc_path: str) -> str | None:
|
||||
"""Map a Nextcloud-internal path (`/admin/files/Photos/foo.jpg`) to
|
||||
the absolute bind-mount path mule's workers operate on
|
||||
(`/nextcloud-users/admin/files/Photos/foo.jpg`).
|
||||
|
||||
Returns None for paths that don't sit under `<user>/files/...`
|
||||
(NC also emits events for trashbin, versions, etc — we ignore
|
||||
those).
|
||||
"""
|
||||
if not nc_path or not nc_path.startswith("/"):
|
||||
return None
|
||||
parts = nc_path.lstrip("/").split("/", 2)
|
||||
if len(parts) < 3 or parts[1] != "files":
|
||||
return None
|
||||
return os.path.join(NEXTCLOUD_USERS_ROOT, parts[0], "files", parts[2])
|
||||
|
||||
|
||||
def _classify(event_class: str) -> str | None:
|
||||
"""Bucket the full event class string into the four buckets we act
|
||||
on. Returns None for events we don't care about (Before*, copy,
|
||||
touched, etc)."""
|
||||
short = event_class.rsplit("\\", 1)[-1]
|
||||
return {
|
||||
"NodeCreatedEvent": "created",
|
||||
"NodeWrittenEvent": "written",
|
||||
"NodeDeletedEvent": "deleted",
|
||||
"NodeRenamedEvent": "renamed",
|
||||
}.get(short)
|
||||
|
||||
|
||||
async def _source_root_for(parent_dir: str) -> str | None:
|
||||
"""Find the SourceRoot id whose path contains `parent_dir`."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
)
|
||||
roots = result.scalars().all()
|
||||
normalized = os.path.normpath(parent_dir)
|
||||
for sr in roots:
|
||||
root_path = os.path.normpath(sr.path)
|
||||
if normalized == root_path or normalized.startswith(root_path + os.sep):
|
||||
return sr.id
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/nc-webhook")
|
||||
async def nc_webhook(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""Receive a Nextcloud file event and dispatch the matching
|
||||
scan_folder / handle_file_deletion task. Returns 204 on success
|
||||
(Nextcloud doesn't care about the body)."""
|
||||
expected = _expected_secret()
|
||||
if not expected:
|
||||
# Fail closed: a misconfigured server should reject webhooks
|
||||
# rather than accept arbitrary POSTs.
|
||||
logger.error("nc-webhook hit but NEXTCLOUD_WEBHOOK_SECRET is not set")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
presented = authorization[len("Bearer "):]
|
||||
if not hmac.compare_digest(presented, expected):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
try:
|
||||
payload: dict[str, Any] = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="malformed json")
|
||||
|
||||
event = payload.get("event") or {}
|
||||
event_class = event.get("class") or ""
|
||||
bucket = _classify(event_class)
|
||||
if bucket is None:
|
||||
return {"status": "ignored", "reason": "unwanted event"}
|
||||
|
||||
# Import lazily so this router can load before the celery app is
|
||||
# ready — important when the backend boots before broker is up.
|
||||
from app.tasks.scan import (
|
||||
scan_folder,
|
||||
handle_file_deletion,
|
||||
handle_directory_deletion,
|
||||
handle_directory_rename,
|
||||
)
|
||||
|
||||
supported = _supported_extensions()
|
||||
|
||||
if bucket in ("created", "written"):
|
||||
node = event.get("node") or {}
|
||||
nc_path = node.get("path")
|
||||
abs_path = _nc_path_to_abs(nc_path) if nc_path else None
|
||||
if not abs_path:
|
||||
return {"status": "ignored", "reason": "non-user-files path"}
|
||||
if Path(abs_path).suffix.lower() not in supported:
|
||||
return {"status": "ignored", "reason": "unsupported extension"}
|
||||
parent_dir = str(Path(abs_path).parent)
|
||||
source_root_id = await _source_root_for(parent_dir)
|
||||
if source_root_id is None:
|
||||
# Outside any registered source root — we don't index this
|
||||
# part of Nextcloud at all.
|
||||
return {"status": "ignored", "reason": "outside source root"}
|
||||
scan_folder.delay(parent_dir, source_root_id)
|
||||
logger.info("nc-webhook %s: queued scan_folder for %s", bucket, parent_dir)
|
||||
return {"status": "queued", "action": "scan_folder", "path": parent_dir}
|
||||
|
||||
if bucket == "deleted":
|
||||
node = event.get("node") or {}
|
||||
nc_path = node.get("path")
|
||||
abs_path = _nc_path_to_abs(nc_path) if nc_path else None
|
||||
if not abs_path:
|
||||
return {"status": "ignored", "reason": "non-user-files path"}
|
||||
# Folder deletes: NC fires exactly one NodeDeletedEvent for the
|
||||
# folder, not one per child file. Detect the directory case by
|
||||
# the absence of a supported image extension and recursively
|
||||
# discard every Photo under that prefix.
|
||||
if Path(abs_path).suffix.lower() not in supported:
|
||||
n = await handle_directory_deletion(abs_path)
|
||||
logger.info(
|
||||
"nc-webhook deleted (dir): %s -> %s photos discarded",
|
||||
abs_path, n,
|
||||
)
|
||||
return {
|
||||
"status": "applied",
|
||||
"action": "discard_subtree",
|
||||
"path": abs_path,
|
||||
"discarded": n,
|
||||
}
|
||||
await handle_file_deletion(abs_path)
|
||||
logger.info("nc-webhook deleted: marked %s as discarded", abs_path)
|
||||
return {"status": "applied", "action": "discard", "path": abs_path}
|
||||
|
||||
if bucket == "renamed":
|
||||
source = event.get("source") or {}
|
||||
target = event.get("target") or {}
|
||||
old_abs = _nc_path_to_abs(source.get("path") or "")
|
||||
new_abs = _nc_path_to_abs(target.get("path") or "")
|
||||
if not old_abs or not new_abs:
|
||||
return {"status": "ignored", "reason": "non-user-files path"}
|
||||
|
||||
old_is_dir = Path(old_abs).suffix.lower() not in supported
|
||||
new_is_dir = Path(new_abs).suffix.lower() not in supported
|
||||
|
||||
# Directory rename: NC fires one event for the directory; the
|
||||
# children's paths change implicitly. Prefix-rewrite in mule.
|
||||
# Same handler covers the feedback case where the PATCH
|
||||
# /folders/{id}/rename endpoint already updated the DB — the
|
||||
# SQL UPDATE matches zero rows the second time around.
|
||||
if old_is_dir and new_is_dir:
|
||||
result = await handle_directory_rename(old_abs, new_abs)
|
||||
logger.info(
|
||||
"nc-webhook renamed (dir): %s -> %s : %s",
|
||||
old_abs, new_abs, result,
|
||||
)
|
||||
return {
|
||||
"status": "applied",
|
||||
"action": "rename_subtree",
|
||||
"from": old_abs,
|
||||
"to": new_abs,
|
||||
**result,
|
||||
}
|
||||
|
||||
# File rename (existing logic).
|
||||
if Path(old_abs).suffix.lower() in supported:
|
||||
await handle_file_deletion(old_abs)
|
||||
if Path(new_abs).suffix.lower() in supported:
|
||||
parent_dir = str(Path(new_abs).parent)
|
||||
source_root_id = await _source_root_for(parent_dir)
|
||||
if source_root_id is not None:
|
||||
scan_folder.delay(parent_dir, source_root_id)
|
||||
logger.info("nc-webhook renamed: %s -> %s", old_abs, new_abs)
|
||||
return {"status": "applied", "action": "rename", "from": old_abs, "to": new_abs}
|
||||
|
||||
# Shouldn't reach here — classify() already filtered.
|
||||
return {"status": "ignored"}
|
||||
@@ -1,387 +0,0 @@
|
||||
"""Nextcloud integration router — folder picker + per-user SourceRoots.
|
||||
|
||||
Exposes three things:
|
||||
|
||||
- GET /api/v1/nextcloud/whoami?candidate=<name>
|
||||
Validate that a Nextcloud username actually has a files/ tree
|
||||
on the mounted homecloud volume. Used by the Settings UI to
|
||||
sanity-check the override field before saving.
|
||||
|
||||
- GET /api/v1/nextcloud/browse?path=<rel>
|
||||
List immediate subdirectories of the current user's Nextcloud
|
||||
files tree, scoped server-side to their nextcloud_username.
|
||||
Powers the folder picker.
|
||||
|
||||
- POST /api/v1/nextcloud/source-roots {name, nextcloud_path}
|
||||
DELETE /api/v1/nextcloud/source-roots/{id}
|
||||
Add or remove a per-user SourceRoot pointing at a Nextcloud
|
||||
subfolder. Adding kicks off an immediate scan_folder task so
|
||||
photos start appearing without a full library re-scan.
|
||||
|
||||
All paths are normalized with realpath and rejected if they escape the
|
||||
user's allowed root — defense-in-depth against `..` and symlink
|
||||
shenanigans.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.folders import SourceRoot, Folder
|
||||
from app.models.photos import Photo
|
||||
from app.models.sharing import FolderShare
|
||||
from app.models.user import User
|
||||
from app.services.nextcloud_dav import (
|
||||
NEXTCLOUD_USERS_ROOT,
|
||||
is_nextcloud_path,
|
||||
whoami_dir_exists,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_root(nc_username: str) -> str:
|
||||
"""Absolute path of `<NEXTCLOUD_USERS_ROOT>/<user>/files`."""
|
||||
return os.path.join(NEXTCLOUD_USERS_ROOT, nc_username, "files")
|
||||
|
||||
|
||||
def _resolve_under_user_root(nc_username: str, rel: str) -> str:
|
||||
"""Resolve `rel` (a relative path the client supplied) under the
|
||||
user's Nextcloud `files/` directory and ensure the result is still
|
||||
inside that root. Returns the absolute, realpath-normalized path.
|
||||
|
||||
Raises 400 on traversal attempts (`..`, absolute paths, symlinks
|
||||
that point outside the root)."""
|
||||
rel = (rel or "").lstrip("/")
|
||||
if any(seg in ("..",) for seg in rel.split("/") if seg):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
base = _user_root(nc_username)
|
||||
candidate = os.path.realpath(os.path.join(base, rel))
|
||||
base_real = os.path.realpath(base)
|
||||
if candidate != base_real and not candidate.startswith(base_real + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Path escapes Nextcloud root")
|
||||
return candidate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BrowseEntry(BaseModel):
|
||||
name: str
|
||||
path: str # path relative to the user's files/ root
|
||||
has_children: bool # True if it contains at least one sub-directory
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
nc_username: str
|
||||
rel_path: str
|
||||
parent_rel: Optional[str] # None at the root
|
||||
entries: list[BrowseEntry]
|
||||
|
||||
|
||||
@router.get("/whoami")
|
||||
async def whoami(
|
||||
candidate: Optional[str] = Query(None, description="Nextcloud username to validate"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Public to authenticated users — returns whether a Nextcloud
|
||||
username has a real files/ directory on the bind mount. Used by
|
||||
the Settings UI to validate the override field before save."""
|
||||
name = (candidate or current_user.nextcloud_username or "").strip()
|
||||
if not name:
|
||||
return {
|
||||
"configured": bool(current_user.nextcloud_username),
|
||||
"candidate": None,
|
||||
"valid": False,
|
||||
"reason": "no_username",
|
||||
}
|
||||
valid = whoami_dir_exists(name)
|
||||
return {
|
||||
"configured": bool(current_user.nextcloud_username),
|
||||
"candidate": name,
|
||||
"valid": valid,
|
||||
"reason": None if valid else "no_files_dir",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/browse", response_model=BrowseResponse)
|
||||
async def browse(
|
||||
path: str = Query("", description="Path relative to the user's NC files/ root"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List immediate subdirectories of the current user's NC tree."""
|
||||
nc_user = (current_user.nextcloud_username or "").strip()
|
||||
if not nc_user:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Set your Nextcloud username in Settings → Library first.",
|
||||
)
|
||||
if not whoami_dir_exists(nc_user):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"Nextcloud user '{nc_user}' has no files/ directory on the "
|
||||
"mounted homecloud volume. Check your nextcloud_username override."
|
||||
),
|
||||
)
|
||||
|
||||
abs_path = _resolve_under_user_root(nc_user, path)
|
||||
if not os.path.isdir(abs_path):
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
base = _user_root(nc_user)
|
||||
entries: list[BrowseEntry] = []
|
||||
try:
|
||||
with os.scandir(abs_path) as it:
|
||||
for de in it:
|
||||
# Skip hidden and Nextcloud's appdata noise.
|
||||
if de.name.startswith("."):
|
||||
continue
|
||||
if not de.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
child_abs = os.path.join(abs_path, de.name)
|
||||
rel = os.path.relpath(child_abs, base)
|
||||
# Quick has_children probe: any subdir that's a real
|
||||
# directory. Cap at first hit so deep trees don't slow
|
||||
# the picker.
|
||||
has_children = False
|
||||
try:
|
||||
with os.scandir(child_abs) as sub:
|
||||
for s in sub:
|
||||
if s.name.startswith("."):
|
||||
continue
|
||||
if s.is_dir(follow_symlinks=False):
|
||||
has_children = True
|
||||
break
|
||||
except OSError:
|
||||
has_children = False
|
||||
entries.append(BrowseEntry(name=de.name, path=rel, has_children=has_children))
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
"Cannot read Nextcloud folder — backend container lacks "
|
||||
"filesystem permissions on the mount. Apply ACL fix on the host."
|
||||
),
|
||||
)
|
||||
|
||||
entries.sort(key=lambda e: e.name.lower())
|
||||
|
||||
rel_path = os.path.relpath(abs_path, _user_root(nc_user)) if abs_path != _user_root(nc_user) else ""
|
||||
if rel_path == ".":
|
||||
rel_path = ""
|
||||
parent_rel: Optional[str] = None
|
||||
if rel_path:
|
||||
parent = os.path.dirname(rel_path)
|
||||
parent_rel = parent
|
||||
|
||||
return BrowseResponse(
|
||||
nc_username=nc_user,
|
||||
rel_path=rel_path,
|
||||
parent_rel=parent_rel,
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SourceRootCreate(BaseModel):
|
||||
name: str
|
||||
nextcloud_path: str # relative to the user's files/ root
|
||||
|
||||
|
||||
@router.post("/source-roots", status_code=201)
|
||||
async def create_nextcloud_source_root(
|
||||
body: SourceRootCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Register a Nextcloud subfolder as a SourceRoot for the current user
|
||||
and kick off the initial scan."""
|
||||
nc_user = (current_user.nextcloud_username or "").strip()
|
||||
if not nc_user:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Set your Nextcloud username in Settings → Library first.",
|
||||
)
|
||||
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
|
||||
abs_path = _resolve_under_user_root(nc_user, body.nextcloud_path)
|
||||
if not os.path.isdir(abs_path):
|
||||
raise HTTPException(status_code=404, detail="Folder not found in Nextcloud tree")
|
||||
|
||||
# Don't allow registering the user's `files/` root itself as a
|
||||
# SourceRoot — it'd index everything they own (Documents, Notes,
|
||||
# appdata noise). Force them to pick a subfolder.
|
||||
if abs_path == os.path.realpath(_user_root(nc_user)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Pick a subfolder; the whole files/ root is too broad.",
|
||||
)
|
||||
|
||||
# Refuse duplicates — the path is uniquely indexed but a clean error
|
||||
# beats a 500 from the unique constraint.
|
||||
existing = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.path == abs_path)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="A SourceRoot for that path already exists")
|
||||
|
||||
sr = SourceRoot(
|
||||
name=name,
|
||||
path=abs_path,
|
||||
user_id=current_user.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sr)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(sr)
|
||||
|
||||
# Kick off the initial scan. Failures here shouldn't block the
|
||||
# SourceRoot creation — the user can hit "Re-scan source folders"
|
||||
# from Settings if Celery is wedged.
|
||||
try:
|
||||
from app.tasks.celery import celery_app
|
||||
celery_app.send_task("scan_folder", args=[sr.path, sr.id])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Failed to queue initial scan for new SourceRoot %s: %s", sr.id, exc)
|
||||
|
||||
return {
|
||||
"id": sr.id,
|
||||
"name": sr.name,
|
||||
"path": sr.path,
|
||||
"user_id": sr.user_id,
|
||||
"is_active": sr.is_active,
|
||||
"is_nextcloud": True,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/source-roots/{source_root_id}")
|
||||
async def delete_nextcloud_source_root(
|
||||
source_root_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Hard-delete: drop the SourceRoot row and cascade-delete every
|
||||
Folder and Photo underneath it. The actual files in Nextcloud are
|
||||
NOT touched — this is just unwiring the user's library in mule-image.
|
||||
|
||||
Implementation notes:
|
||||
- photo_tags and heap_photos cascade via DB-level ON DELETE CASCADE,
|
||||
so deleting Photo rows is enough to clean those up.
|
||||
- FolderShare uses a stringly-typed folder_id (no FK), so we have
|
||||
to clean those rows by hand for both the SourceRoot itself and
|
||||
every Folder we're about to delete.
|
||||
- Chunked at 500 to mirror prune_missing_photos so postgres doesn't
|
||||
choke on a 21k-photo source root.
|
||||
"""
|
||||
sr = (await db.execute(
|
||||
select(SourceRoot).where(
|
||||
SourceRoot.id == source_root_id,
|
||||
SourceRoot.user_id == current_user.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if sr is None:
|
||||
raise HTTPException(status_code=404, detail="SourceRoot not found")
|
||||
if not is_nextcloud_path(sr.path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This endpoint only manages Nextcloud-rooted SourceRoots",
|
||||
)
|
||||
|
||||
folder_ids = (await db.execute(
|
||||
select(Folder.id).where(Folder.source_root_id == sr.id)
|
||||
)).scalars().all()
|
||||
|
||||
photo_ids: list[str] = []
|
||||
if folder_ids:
|
||||
photo_ids = (await db.execute(
|
||||
select(Photo.id).where(Photo.folder_id.in_(folder_ids))
|
||||
)).scalars().all()
|
||||
|
||||
CHUNK = 500
|
||||
for i in range(0, len(photo_ids), CHUNK):
|
||||
chunk = photo_ids[i:i + CHUNK]
|
||||
await db.execute(delete(Photo).where(Photo.id.in_(chunk)))
|
||||
|
||||
# FolderShare rows: not a real FK, clean both 'source_root' and
|
||||
# 'folder' typed shares pointing at anything we're tearing down.
|
||||
await db.execute(
|
||||
delete(FolderShare).where(
|
||||
FolderShare.folder_id == sr.id,
|
||||
FolderShare.folder_type == 'source_root',
|
||||
)
|
||||
)
|
||||
if folder_ids:
|
||||
await db.execute(
|
||||
delete(FolderShare).where(
|
||||
FolderShare.folder_id.in_(folder_ids),
|
||||
FolderShare.folder_type == 'folder',
|
||||
)
|
||||
)
|
||||
# Folders have a self-referential parent_id FK with no
|
||||
# ON DELETE rule. NULL parent_id on every folder that points
|
||||
# *into* our delete set — that includes children within this
|
||||
# source root AND any folder under a different SourceRoot whose
|
||||
# path happens to nest inside this one (e.g. a 'Leóns 1st Year'
|
||||
# SourceRoot at `.../Taco and Muli - 2024 onward/Leóns 1st Year`
|
||||
# has folder rows whose parent_id points at folder rows under
|
||||
# 'Taco and Muli - 2024 onward'). Without this, deleting the
|
||||
# outer SourceRoot trips folders_parent_id_fkey from the inner
|
||||
# SourceRoot's still-live rows.
|
||||
await db.execute(
|
||||
update(Folder)
|
||||
.where(Folder.parent_id.in_(folder_ids))
|
||||
.values(parent_id=None)
|
||||
)
|
||||
await db.execute(delete(Folder).where(Folder.id.in_(folder_ids)))
|
||||
|
||||
await db.delete(sr)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Deleted SourceRoot {sr.id} ({sr.name}): "
|
||||
f"{len(photo_ids)} photos, {len(folder_ids)} folders"
|
||||
)
|
||||
return {
|
||||
"deleted_photos": len(photo_ids),
|
||||
"deleted_folders": len(folder_ids),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/source-roots")
|
||||
async def list_nextcloud_source_roots(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All Nextcloud-rooted SourceRoots owned by the current user.
|
||||
Used by the Settings panel to render the manage list."""
|
||||
rows = (await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.user_id == current_user.id)
|
||||
)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"path": r.path,
|
||||
"is_active": r.is_active,
|
||||
"is_nextcloud": True,
|
||||
}
|
||||
for r in rows
|
||||
if is_nextcloud_path(r.path)
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
Search API router — unified hybrid search endpoint.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.services.search import hybrid_search
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
q: Optional[str] = None
|
||||
filters: Optional[dict] = None
|
||||
limit: int = 50
|
||||
offset: int = 0
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""FTS search over photo metadata with optional tag and date filters."""
|
||||
filters = body.filters or {}
|
||||
|
||||
results = await hybrid_search(
|
||||
db=db,
|
||||
q=body.q,
|
||||
tag_ids=filters.get("tag_ids"),
|
||||
date_from=filters.get("date_from"),
|
||||
date_to=filters.get("date_to"),
|
||||
limit=body.limit,
|
||||
offset=body.offset,
|
||||
)
|
||||
|
||||
if not results:
|
||||
return {"results": [], "total": 0}
|
||||
|
||||
# Hydrate with photo data
|
||||
photo_ids = [r["photo_id"] for r in results]
|
||||
stmt = select(Photo).where(Photo.id.in_(photo_ids), Photo.user_id == current_user.id)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
photo_map = {p.id: p for p in rows}
|
||||
|
||||
hydrated = []
|
||||
for r in results:
|
||||
photo = photo_map.get(r["photo_id"])
|
||||
if not photo:
|
||||
continue
|
||||
hydrated.append({
|
||||
"id": photo.id,
|
||||
"filename": photo.filename,
|
||||
"filepath": photo.filepath,
|
||||
"media_type": photo.media_type,
|
||||
"width": photo.width,
|
||||
"height": photo.height,
|
||||
"taken_at": photo.taken_at.isoformat() if photo.taken_at else None,
|
||||
"rating": photo.rating,
|
||||
"color_label": photo.color_label,
|
||||
"thumb_small": photo.thumb_small,
|
||||
"thumb_medium": photo.thumb_medium,
|
||||
"score": r["score"],
|
||||
})
|
||||
|
||||
return {"results": hydrated, "total": len(hydrated)}
|
||||
@@ -1,613 +0,0 @@
|
||||
"""
|
||||
Sharing API router — manage cross-user access to heaps and folders.
|
||||
"""
|
||||
import logging
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.heaps import Heap, heap_photos
|
||||
from app.models.folders import Folder, SourceRoot
|
||||
from app.models.photos import Photo
|
||||
from app.models.sharing import HeapShare, FolderShare
|
||||
from app.models.user import User
|
||||
from app.dependencies import (
|
||||
get_current_user,
|
||||
get_user_heap,
|
||||
get_user_folder,
|
||||
resolve_username,
|
||||
)
|
||||
from app.services.gravatar import gravatar_url
|
||||
|
||||
|
||||
def _user_avatar(user: User) -> Optional[str]:
|
||||
"""OIDC `picture` claim wins, Gravatar fills the gap. Returns None
|
||||
when neither source can produce a URL so the frontend can fall back
|
||||
to the initials bubble."""
|
||||
return user.avatar_url or gravatar_url(user.email)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sharing", tags=["sharing"])
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ShareCreate(BaseModel):
|
||||
username: str
|
||||
permission: Literal["read", "write"] = "read"
|
||||
|
||||
|
||||
class ShareResponse(BaseModel):
|
||||
id: str
|
||||
shared_with_id: str
|
||||
shared_with_username: str
|
||||
shared_with_avatar_url: Optional[str] = None
|
||||
shared_with_display_name: Optional[str] = None
|
||||
permission: str
|
||||
status: str # 'pending' | 'accepted'
|
||||
created_at: str
|
||||
|
||||
|
||||
class SharedHeapResponse(BaseModel):
|
||||
# `id` is the heap id (used for navigation). `share_id` is the
|
||||
# heap_shares row id, needed so the recipient can "Leave" via the
|
||||
# existing DELETE endpoint without a separate lookup.
|
||||
id: str
|
||||
share_id: str
|
||||
name: str
|
||||
owner_username: str
|
||||
owner_avatar_url: Optional[str] = None
|
||||
owner_display_name: Optional[str] = None
|
||||
permission: str
|
||||
photo_count: int
|
||||
|
||||
|
||||
class SharedFolderResponse(BaseModel):
|
||||
id: str
|
||||
share_id: str
|
||||
name: str
|
||||
folder_type: str
|
||||
owner_username: str
|
||||
owner_avatar_url: Optional[str] = None
|
||||
owner_display_name: Optional[str] = None
|
||||
permission: str
|
||||
photo_count: int
|
||||
|
||||
|
||||
class PendingInvite(BaseModel):
|
||||
"""A share that exists in the DB but hasn't been accepted yet. Powers
|
||||
the notification bell in the left-sidebar user section."""
|
||||
share_id: str
|
||||
target_id: str # heap id or folder id
|
||||
target_name: str
|
||||
owner_username: str
|
||||
owner_avatar_url: Optional[str] = None
|
||||
owner_display_name: Optional[str] = None
|
||||
permission: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class PendingInvitesResponse(BaseModel):
|
||||
heaps: list[PendingInvite]
|
||||
folders: list[PendingInvite]
|
||||
|
||||
|
||||
class ShareableUser(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
avatar_url: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
# ── Shareable users ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users", response_model=list[ShareableUser])
|
||||
async def list_shareable_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List every active user other than the caller, for the share-picker
|
||||
dropdown. Sharing only requires knowing a username today, so surfacing
|
||||
the list is no wider an attack surface than the free-text input it
|
||||
replaces. Inactive accounts are filtered out."""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id != current_user.id)
|
||||
.where(User.is_active.is_(True))
|
||||
.order_by(User.username)
|
||||
)
|
||||
return [
|
||||
ShareableUser(
|
||||
id=str(u.id),
|
||||
username=u.username,
|
||||
avatar_url=_user_avatar(u),
|
||||
display_name=u.display_name,
|
||||
)
|
||||
for u in result.scalars().all()
|
||||
]
|
||||
|
||||
|
||||
# ── Pending invites (recipient-facing, cross-type) ───────────────────────
|
||||
|
||||
@router.get("/pending", response_model=PendingInvitesResponse)
|
||||
async def list_pending_invites(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Every share targeting the current user that's still waiting on
|
||||
them to accept. Feeds the notification bell in the sidebar."""
|
||||
heap_rows = (await db.execute(
|
||||
select(HeapShare, Heap, User)
|
||||
.join(Heap, HeapShare.heap_id == Heap.id)
|
||||
.join(User, HeapShare.owner_id == User.id)
|
||||
.where(HeapShare.shared_with_id == current_user.id)
|
||||
.where(HeapShare.status == "pending")
|
||||
)).all()
|
||||
|
||||
heaps = [
|
||||
PendingInvite(
|
||||
share_id=share.id,
|
||||
target_id=heap.id,
|
||||
target_name=heap.name,
|
||||
owner_username=owner.username,
|
||||
owner_avatar_url=_user_avatar(owner),
|
||||
owner_display_name=owner.display_name,
|
||||
permission=share.permission,
|
||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||
)
|
||||
for share, heap, owner in heap_rows
|
||||
]
|
||||
|
||||
folder_rows = (await db.execute(
|
||||
select(FolderShare, User)
|
||||
.join(User, FolderShare.owner_id == User.id)
|
||||
.where(FolderShare.shared_with_id == current_user.id)
|
||||
.where(FolderShare.status == "pending")
|
||||
)).all()
|
||||
|
||||
folders: list[PendingInvite] = []
|
||||
for share, owner in folder_rows:
|
||||
if share.folder_type == "source_root":
|
||||
entity = (await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == share.folder_id)
|
||||
)).scalar_one_or_none()
|
||||
else:
|
||||
entity = (await db.execute(
|
||||
select(Folder).where(Folder.id == share.folder_id)
|
||||
)).scalar_one_or_none()
|
||||
# If the underlying folder was deleted while an invite was
|
||||
# still pending, just skip — the share is effectively orphaned
|
||||
# and the owner's revoke path will clean it up.
|
||||
if entity is None:
|
||||
continue
|
||||
folders.append(PendingInvite(
|
||||
share_id=share.id,
|
||||
target_id=share.folder_id,
|
||||
target_name=entity.name,
|
||||
owner_username=owner.username,
|
||||
owner_avatar_url=_user_avatar(owner),
|
||||
owner_display_name=owner.display_name,
|
||||
permission=share.permission,
|
||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||
))
|
||||
|
||||
return PendingInvitesResponse(heaps=heaps, folders=folders)
|
||||
|
||||
|
||||
# ── Heap sharing ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/heaps/shared-with-me")
|
||||
async def list_shared_heaps(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all accepted heap shares for the current user. Pending
|
||||
invites are hidden here and surfaced via /sharing/pending instead."""
|
||||
result = await db.execute(
|
||||
select(HeapShare, Heap, User)
|
||||
.join(Heap, HeapShare.heap_id == Heap.id)
|
||||
.join(User, HeapShare.owner_id == User.id)
|
||||
.where(HeapShare.shared_with_id == current_user.id)
|
||||
.where(HeapShare.status == "accepted")
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for share, heap, owner in rows:
|
||||
# Count photos in this heap.
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(heap_photos).where(
|
||||
heap_photos.c.heap_id == heap.id
|
||||
)
|
||||
)
|
||||
count = count_result.scalar() or 0
|
||||
|
||||
items.append(SharedHeapResponse(
|
||||
id=heap.id,
|
||||
share_id=share.id,
|
||||
name=heap.name,
|
||||
owner_username=owner.username,
|
||||
owner_avatar_url=_user_avatar(owner),
|
||||
owner_display_name=owner.display_name,
|
||||
permission=share.permission,
|
||||
photo_count=count,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/heaps/{heap_id}")
|
||||
async def list_heap_shares(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all shares for a heap (owner only)."""
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(HeapShare, User)
|
||||
.join(User, HeapShare.shared_with_id == User.id)
|
||||
.where(HeapShare.heap_id == heap.id)
|
||||
)
|
||||
return [
|
||||
ShareResponse(
|
||||
id=share.id,
|
||||
shared_with_id=user.id,
|
||||
shared_with_username=user.username,
|
||||
shared_with_avatar_url=_user_avatar(user),
|
||||
shared_with_display_name=user.display_name,
|
||||
permission=share.permission,
|
||||
status=share.status,
|
||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||
)
|
||||
for share, user in result.all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/heaps/{heap_id}", status_code=201)
|
||||
async def share_heap(
|
||||
heap_id: str,
|
||||
body: ShareCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Share a heap with another user (owner only)."""
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
target_user = await resolve_username(body.username, db)
|
||||
|
||||
if target_user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot share with yourself")
|
||||
|
||||
# Check for existing share.
|
||||
existing = await db.execute(
|
||||
select(HeapShare).where(
|
||||
HeapShare.heap_id == heap.id,
|
||||
HeapShare.shared_with_id == target_user.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Already shared with this user")
|
||||
|
||||
share = HeapShare(
|
||||
heap_id=heap.id,
|
||||
owner_id=current_user.id,
|
||||
shared_with_id=target_user.id,
|
||||
permission=body.permission,
|
||||
)
|
||||
db.add(share)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Heap %s shared with %s (%s)", heap.name, target_user.username, body.permission)
|
||||
return {"status": "shared", "share_id": share.id}
|
||||
|
||||
|
||||
@router.post("/heaps/{heap_id}/accept", status_code=200)
|
||||
async def accept_heap_share(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Recipient accepts a pending heap invite. Idempotent — if the
|
||||
share is already accepted, returns 200 anyway so double-clicks in
|
||||
the notification popover are harmless."""
|
||||
result = await db.execute(
|
||||
select(HeapShare).where(
|
||||
HeapShare.heap_id == heap_id,
|
||||
HeapShare.shared_with_id == current_user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
if share.status != "accepted":
|
||||
share.status = "accepted"
|
||||
share.accepted_at = func.now()
|
||||
await db.commit()
|
||||
return {"status": "accepted"}
|
||||
|
||||
|
||||
@router.post("/heaps/{heap_id}/decline", status_code=200)
|
||||
async def decline_heap_share(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Recipient declines a pending heap invite. The share row is
|
||||
deleted — there's no separate 'declined' status. A re-invite just
|
||||
creates a fresh pending row."""
|
||||
result = await db.execute(
|
||||
select(HeapShare).where(
|
||||
HeapShare.heap_id == heap_id,
|
||||
HeapShare.shared_with_id == current_user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
await db.delete(share)
|
||||
await db.commit()
|
||||
return {"status": "declined"}
|
||||
|
||||
|
||||
@router.delete("/heaps/{heap_id}/{share_id}", status_code=204)
|
||||
async def revoke_heap_share(
|
||||
heap_id: str,
|
||||
share_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Revoke a heap share. The owner can revoke any share; a recipient
|
||||
can revoke their own share (i.e. leave)."""
|
||||
result = await db.execute(
|
||||
select(HeapShare).where(HeapShare.id == share_id, HeapShare.heap_id == heap_id)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
# Must be the owner or the recipient themselves.
|
||||
if share.owner_id != current_user.id and share.shared_with_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
await db.delete(share)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Folder sharing ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/folders/shared-with-me")
|
||||
async def list_shared_folders(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all accepted folder/source-root shares for the current
|
||||
user. Pending invites are hidden here and surfaced via
|
||||
/sharing/pending instead."""
|
||||
result = await db.execute(
|
||||
select(FolderShare, User)
|
||||
.join(User, FolderShare.owner_id == User.id)
|
||||
.where(FolderShare.shared_with_id == current_user.id)
|
||||
.where(FolderShare.status == "accepted")
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for share, owner in rows:
|
||||
# Resolve the folder/source root name and photo count.
|
||||
if share.folder_type == "source_root":
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == share.folder_id)
|
||||
)
|
||||
entity = sr_result.scalar_one_or_none()
|
||||
if not entity:
|
||||
continue
|
||||
name = entity.name
|
||||
# Count all photos under this source root's folders.
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(Photo).where(
|
||||
Photo.folder_id.in_(
|
||||
select(Folder.id).where(Folder.source_root_id == entity.id)
|
||||
),
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
)
|
||||
else:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.id == share.folder_id)
|
||||
)
|
||||
entity = folder_result.scalar_one_or_none()
|
||||
if not entity:
|
||||
continue
|
||||
name = entity.name
|
||||
import os
|
||||
target_path = os.path.normpath(entity.path).rstrip(os.sep)
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(Photo).where(
|
||||
Photo.folder_id.in_(
|
||||
select(Folder.id).where(
|
||||
(Folder.path == target_path)
|
||||
| (Folder.path.like(target_path + os.sep + "%"))
|
||||
)
|
||||
),
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
)
|
||||
|
||||
count = count_result.scalar() or 0
|
||||
items.append(SharedFolderResponse(
|
||||
id=share.folder_id,
|
||||
share_id=share.id,
|
||||
name=name,
|
||||
folder_type=share.folder_type,
|
||||
owner_username=owner.username,
|
||||
owner_avatar_url=_user_avatar(owner),
|
||||
owner_display_name=owner.display_name,
|
||||
permission=share.permission,
|
||||
photo_count=count,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}")
|
||||
async def list_folder_shares(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all shares for a folder (owner only)."""
|
||||
# Verify ownership — try folder then source root.
|
||||
owned = False
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
owned = True
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
owned = True
|
||||
|
||||
if not owned:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(FolderShare, User)
|
||||
.join(User, FolderShare.shared_with_id == User.id)
|
||||
.where(FolderShare.folder_id == folder_id)
|
||||
)
|
||||
return [
|
||||
ShareResponse(
|
||||
id=share.id,
|
||||
shared_with_id=user.id,
|
||||
shared_with_username=user.username,
|
||||
shared_with_avatar_url=_user_avatar(user),
|
||||
shared_with_display_name=user.display_name,
|
||||
permission=share.permission,
|
||||
status=share.status,
|
||||
created_at=share.created_at.isoformat() if share.created_at else "",
|
||||
)
|
||||
for share, user in result.all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}", status_code=201)
|
||||
async def share_folder(
|
||||
folder_id: str,
|
||||
body: ShareCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Share a folder or source root with another user (owner only)."""
|
||||
# Determine folder_type and verify ownership.
|
||||
folder_type = "folder"
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
if entity is None:
|
||||
result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder_type = "source_root"
|
||||
|
||||
target_user = await resolve_username(body.username, db)
|
||||
if target_user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot share with yourself")
|
||||
|
||||
existing = await db.execute(
|
||||
select(FolderShare).where(
|
||||
FolderShare.folder_id == folder_id,
|
||||
FolderShare.shared_with_id == target_user.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Already shared with this user")
|
||||
|
||||
share = FolderShare(
|
||||
folder_id=folder_id,
|
||||
folder_type=folder_type,
|
||||
owner_id=current_user.id,
|
||||
shared_with_id=target_user.id,
|
||||
permission=body.permission,
|
||||
)
|
||||
db.add(share)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Folder %s shared with %s (%s)", entity.name, target_user.username, body.permission)
|
||||
return {"status": "shared", "share_id": share.id}
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}/accept", status_code=200)
|
||||
async def accept_folder_share(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Recipient accepts a pending folder invite. Idempotent."""
|
||||
result = await db.execute(
|
||||
select(FolderShare).where(
|
||||
FolderShare.folder_id == folder_id,
|
||||
FolderShare.shared_with_id == current_user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
if share.status != "accepted":
|
||||
share.status = "accepted"
|
||||
share.accepted_at = func.now()
|
||||
await db.commit()
|
||||
return {"status": "accepted"}
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}/decline", status_code=200)
|
||||
async def decline_folder_share(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Recipient declines a pending folder invite. Row is deleted."""
|
||||
result = await db.execute(
|
||||
select(FolderShare).where(
|
||||
FolderShare.folder_id == folder_id,
|
||||
FolderShare.shared_with_id == current_user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
await db.delete(share)
|
||||
await db.commit()
|
||||
return {"status": "declined"}
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_id}/{share_id}", status_code=204)
|
||||
async def revoke_folder_share(
|
||||
folder_id: str,
|
||||
share_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Revoke a folder share (owner or self-remove)."""
|
||||
result = await db.execute(
|
||||
select(FolderShare).where(FolderShare.id == share_id, FolderShare.folder_id == folder_id)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
if share.owner_id != current_user.id and share.shared_with_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
await db.delete(share)
|
||||
await db.commit()
|
||||
@@ -1,155 +0,0 @@
|
||||
"""
|
||||
Tags API router.
|
||||
|
||||
Unified across user tags and the binary content-type classifier
|
||||
('photography' | 'other') via the `kind` column.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo, Tag
|
||||
from app.models.tags import photo_tags
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
kind: str = "user"
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_tags(
|
||||
kind: Optional[str] = Query(None, description="Filter by kind: user, content_type"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all tags with their photo counts, optionally filtered by kind.
|
||||
|
||||
Photo counts here drive the Tags / People sidebar badges, so they
|
||||
exclude discarded + hidden-folder photos to match the rest of the
|
||||
cross-cutting views. A tag that only appears on hidden-folder
|
||||
photos will still show up with count=0 — we don't drop empty tags
|
||||
because the user may want to see them in the management UI.
|
||||
"""
|
||||
count_subq = (
|
||||
select(
|
||||
photo_tags.c.tag_id,
|
||||
func.count(photo_tags.c.photo_id).label("photo_count"),
|
||||
func.min(photo_tags.c.photo_id).label("first_photo_id"),
|
||||
)
|
||||
.select_from(
|
||||
photo_tags.join(Photo, Photo.id == photo_tags.c.photo_id)
|
||||
)
|
||||
.where(
|
||||
Photo.user_id == current_user.id,
|
||||
Photo.is_discarded.is_(False),
|
||||
Photo.is_hidden.is_(False),
|
||||
)
|
||||
.group_by(photo_tags.c.tag_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
|
||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||
.where(Tag.user_id == current_user.id)
|
||||
)
|
||||
if kind:
|
||||
stmt = stmt.where(Tag.kind == kind)
|
||||
stmt = stmt.order_by(Tag.name.asc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"color": tag.color,
|
||||
"kind": tag.kind,
|
||||
"source": tag.source,
|
||||
"representative_photo_id": first_photo_id,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for tag, count, first_photo_id in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Create a new tag. The (name, kind) pair is unique — re-creating an
|
||||
existing pair returns the existing row (idempotent for autocomplete)."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||
|
||||
existing = await db.execute(
|
||||
select(Tag).where(Tag.name == name, Tag.kind == body.kind, Tag.user_id == current_user.id)
|
||||
)
|
||||
found = existing.scalar_one_or_none()
|
||||
if found:
|
||||
return {
|
||||
"id": found.id, "name": found.name, "color": found.color,
|
||||
"kind": found.kind, "photo_count": 0,
|
||||
}
|
||||
|
||||
tag = Tag(name=name, color=body.color, kind=body.kind, user_id=current_user.id)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return {
|
||||
"id": tag.id, "name": tag.name, "color": tag.color,
|
||||
"kind": tag.kind, "photo_count": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
async def update_tag(
|
||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename or recolor a tag."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||
tag.name = name
|
||||
if body.color is not None:
|
||||
tag.color = body.color or None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "kind": tag.kind}
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Delete a tag. Photo associations cascade-delete via the FK."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
await db.delete(tag)
|
||||
await db.commit()
|
||||
return None
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Pydantic schemas for photos
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
class PhotoBase(BaseModel):
|
||||
"""Base photo schema"""
|
||||
filename: str
|
||||
media_type: str
|
||||
original_format: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
file_size: Optional[int] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
taken_at_source: Optional[str] = None
|
||||
user_title: Optional[str] = None
|
||||
user_notes: Optional[str] = None
|
||||
rating: int = 0
|
||||
color_label: Optional[str] = None
|
||||
|
||||
class PhotoResponse(PhotoBase):
|
||||
"""Photo response schema"""
|
||||
id: str
|
||||
filepath: str
|
||||
folder_id: Optional[str] = None
|
||||
file_hash: Optional[str] = None
|
||||
added_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
is_discarded: bool = False
|
||||
discarded_at: Optional[datetime] = None
|
||||
thumb_small: Optional[str] = None
|
||||
thumb_medium: Optional[str] = None
|
||||
thumb_large: Optional[str] = None
|
||||
processing_status: str = 'pending'
|
||||
processing_error: Optional[str] = None
|
||||
exif_json: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
is_duplicate: bool = False
|
||||
has_date_warning: bool = False
|
||||
live_photo_video_id: Optional[str] = None
|
||||
owner_username: Optional[str] = None
|
||||
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
class PhotoUpdate(BaseModel):
|
||||
"""Photo update schema"""
|
||||
filename: Optional[str] = None
|
||||
user_title: Optional[str] = None
|
||||
user_notes: Optional[str] = None
|
||||
rating: Optional[int] = Field(None, ge=0, le=5)
|
||||
color_label: Optional[str] = None
|
||||
is_discarded: Optional[bool] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
|
||||
class PhotoListResponse(BaseModel):
|
||||
"""Photo list response with pagination"""
|
||||
photos: List[PhotoResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
class BulkAction(BaseModel):
|
||||
"""Bulk action on photos"""
|
||||
ids: List[str]
|
||||
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color'
|
||||
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
|
||||
@@ -1,447 +0,0 @@
|
||||
"""
|
||||
One-shot data integrity cleanup for source_roots / folders / photos.
|
||||
|
||||
Earlier versions of the scanner stored paths verbatim, so trailing slashes
|
||||
and redundant separators produced duplicate SourceRoot and Folder rows for
|
||||
the same physical directory. The watcher also auto-created source roots
|
||||
when fired with a parent dir. This module merges the duplicates and
|
||||
re-points photos to the canonical folder so the data lines up with the
|
||||
post-fix scanner.
|
||||
|
||||
Idempotent: safe to run on every backend startup.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo, Folder, SourceRoot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
async def _dedupe_source_roots(session: AsyncSession) -> int:
|
||||
"""Group source roots by normalized path and merge duplicates. Returns
|
||||
the number of rows deleted."""
|
||||
result = await session.execute(select(SourceRoot))
|
||||
rows = result.scalars().all()
|
||||
|
||||
groups: dict[str, list[SourceRoot]] = {}
|
||||
for sr in rows:
|
||||
norm = _normalize_path(sr.path)
|
||||
groups.setdefault(norm, []).append(sr)
|
||||
|
||||
deleted = 0
|
||||
for norm, srs in groups.items():
|
||||
if len(srs) == 1:
|
||||
# Make sure the canonical row's path is normalized too.
|
||||
if srs[0].path != norm:
|
||||
srs[0].path = norm
|
||||
continue
|
||||
# Pick the canonical row: prefer one with a non-empty name and the
|
||||
# earliest added_at (most likely the original).
|
||||
canonical = sorted(
|
||||
srs,
|
||||
key=lambda s: (not bool(s.name), s.added_at or datetime.max),
|
||||
)[0]
|
||||
canonical.path = norm
|
||||
for sr in srs:
|
||||
if sr.id == canonical.id:
|
||||
continue
|
||||
# Re-point folders that referenced the duplicate root.
|
||||
await session.execute(
|
||||
update(Folder)
|
||||
.where(Folder.source_root_id == sr.id)
|
||||
.values(source_root_id=canonical.id)
|
||||
)
|
||||
await session.delete(sr)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
async def _dedupe_folders(session: AsyncSession) -> int:
|
||||
"""Group folders by normalized path and merge duplicates. Returns the
|
||||
number of rows deleted."""
|
||||
result = await session.execute(select(Folder))
|
||||
rows = result.scalars().all()
|
||||
|
||||
groups: dict[str, list[Folder]] = {}
|
||||
for f in rows:
|
||||
norm = _normalize_path(f.path)
|
||||
groups.setdefault(norm, []).append(f)
|
||||
|
||||
deleted = 0
|
||||
for norm, folders in groups.items():
|
||||
if len(folders) == 1:
|
||||
if folders[0].path != norm:
|
||||
folders[0].path = norm
|
||||
continue
|
||||
# Canonical = the one with the most photos already attached, then
|
||||
# the lowest-id (deterministic tiebreaker).
|
||||
canonical = sorted(
|
||||
folders,
|
||||
key=lambda f: (-(f.photo_count or 0), f.id),
|
||||
)[0]
|
||||
canonical.path = norm
|
||||
for f in folders:
|
||||
if f.id == canonical.id:
|
||||
continue
|
||||
# Re-point photos to the canonical folder.
|
||||
await session.execute(
|
||||
update(Photo)
|
||||
.where(Photo.folder_id == f.id)
|
||||
.values(folder_id=canonical.id)
|
||||
)
|
||||
await session.delete(f)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
async def _recompute_folder_counts(session: AsyncSession) -> None:
|
||||
"""Set folder.photo_count to the actual non-discarded photo count."""
|
||||
result = await session.execute(select(Folder))
|
||||
folders = result.scalars().all()
|
||||
for f in folders:
|
||||
count_result = await session.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
Photo.folder_id == f.id,
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
f.photo_count = int(count_result.scalar() or 0)
|
||||
|
||||
|
||||
def _parent_is_accessible(path: str) -> bool:
|
||||
"""True if the parent directory of `path` is readable. Used to
|
||||
distinguish 'user renamed/deleted the source root folder' (parent
|
||||
mount fine, leaf gone) from 'drive unmounted' (whole subtree
|
||||
inaccessible). The former is safe to prune from; the latter is
|
||||
not."""
|
||||
parent = os.path.dirname(path.rstrip(os.sep))
|
||||
if not parent:
|
||||
return False
|
||||
try:
|
||||
os.listdir(parent)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _sr_state(sr_path: str) -> str:
|
||||
"""Classify a source root path as one of:
|
||||
'present' — directory exists, business as usual
|
||||
'renamed' — leaf missing but parent mount is accessible (user
|
||||
renamed/deleted the folder in their file manager)
|
||||
'unmounted'— parent itself inaccessible (drive not mounted)
|
||||
"""
|
||||
if os.path.isdir(sr_path):
|
||||
return 'present'
|
||||
if _parent_is_accessible(sr_path):
|
||||
return 'renamed'
|
||||
return 'unmounted'
|
||||
|
||||
|
||||
async def _warn_stale_source_roots(session: AsyncSession) -> int:
|
||||
"""Log a warning for any active source root whose path no longer exists
|
||||
on disk. Doesn't delete — a missing path could be a temporarily
|
||||
unmounted drive, and silently dropping user data is worse than
|
||||
surfacing a noisy log line. Logs different hints for renamed-vs-
|
||||
unmounted so the user knows which knob to turn.
|
||||
"""
|
||||
result = await session.execute(select(SourceRoot))
|
||||
rows = result.scalars().all()
|
||||
stale = 0
|
||||
for sr in rows:
|
||||
state = _sr_state(sr.path)
|
||||
if state == 'present':
|
||||
continue
|
||||
stale += 1
|
||||
if state == 'renamed':
|
||||
logger.warning(
|
||||
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
|
||||
f"— parent mount is fine, looks like the folder was renamed "
|
||||
f"or deleted. Photos under it can be cleared via "
|
||||
f"POST /api/v1/library/maintenance/prune-missing."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
|
||||
f"— parent directory is also inaccessible; is the docker "
|
||||
f"mount still in place? (Edit docker-compose.yml or "
|
||||
f"PHOTO_DIRS in .env to fix.)"
|
||||
)
|
||||
return stale
|
||||
|
||||
|
||||
async def find_missing(
|
||||
session: AsyncSession,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Walk every non-discarded photo + every folder and check whether
|
||||
they still resolve on disk. Returns
|
||||
(deletable_photo_ids, deletable_folder_ids, skipped_photo_ids).
|
||||
|
||||
Skipped rows are photos/folders whose owning source_root is truly
|
||||
inaccessible (parent mount missing) — that's almost always an
|
||||
unmounted drive, and silently deleting those rows would be data
|
||||
loss. Photos under a source root whose leaf is missing but whose
|
||||
parent mount IS accessible (user renamed/deleted the folder) are
|
||||
treated as deletable, since their files are genuinely gone from
|
||||
the user's library.
|
||||
"""
|
||||
sr_rows = (await session.execute(select(SourceRoot))).scalars().all()
|
||||
# "Available" = source root path exists OR parent mount is accessible.
|
||||
# Only truly-unmounted source roots skip pruning.
|
||||
sr_mounted: dict[str, bool] = {
|
||||
sr.id: _sr_state(sr.path) != 'unmounted' for sr in sr_rows
|
||||
}
|
||||
|
||||
photos = (await session.execute(
|
||||
select(Photo.id, Photo.filepath, Photo.folder_id)
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
)).all()
|
||||
|
||||
folders = (await session.execute(
|
||||
select(Folder.id, Folder.path, Folder.source_root_id)
|
||||
)).all()
|
||||
folder_to_sr = {fid: srid for fid, _path, srid in folders}
|
||||
|
||||
deletable_photos: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for pid, fp, folder_id in photos:
|
||||
sr_id = folder_to_sr.get(folder_id)
|
||||
if sr_id is None or not sr_mounted.get(sr_id, False):
|
||||
skipped.append(pid)
|
||||
continue
|
||||
if not os.path.exists(fp):
|
||||
deletable_photos.append(pid)
|
||||
|
||||
deletable_folders: list[str] = []
|
||||
for fid, fpath, sr_id in folders:
|
||||
if sr_id is None or not sr_mounted.get(sr_id, False):
|
||||
continue
|
||||
if not os.path.isdir(fpath):
|
||||
deletable_folders.append(fid)
|
||||
|
||||
return deletable_photos, deletable_folders, skipped
|
||||
|
||||
|
||||
async def prune_missing_photos(dry_run: bool = True) -> dict:
|
||||
"""Delete photo + folder rows whose paths are no longer on disk *and*
|
||||
whose source root is currently mounted. Common cause: PHOTO_DIRS in
|
||||
.env was repointed at a different library, leaving every old row
|
||||
orphaned.
|
||||
|
||||
Set dry_run=False to actually delete. The default is intentionally
|
||||
safe so the matching count can be surfaced in the UI before the
|
||||
user commits to it.
|
||||
|
||||
Function name kept for backwards compatibility — it now also prunes
|
||||
folders, not just photos.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
deletable_photos, deletable_folders, skipped = await find_missing(session)
|
||||
if not dry_run:
|
||||
CHUNK = 500
|
||||
# Photos first (folders may FK from them via folder_id).
|
||||
for i in range(0, len(deletable_photos), CHUNK):
|
||||
await session.execute(
|
||||
delete(Photo).where(
|
||||
Photo.id.in_(deletable_photos[i:i + CHUNK])
|
||||
)
|
||||
)
|
||||
# Then drop folders that ALSO no longer have any photos
|
||||
# pointing at them. We re-check after the photo delete so
|
||||
# we don't strand a folder that legitimately exists on
|
||||
# disk but happened to match the orphan list.
|
||||
if deletable_folders:
|
||||
for i in range(0, len(deletable_folders), CHUNK):
|
||||
chunk = deletable_folders[i:i + CHUNK]
|
||||
# Only delete folders that now have zero photos
|
||||
# left attached (defensive — should always be 0
|
||||
# if the path is gone, but a concurrent scan
|
||||
# could re-create rows).
|
||||
still_used = (await session.execute(
|
||||
select(Photo.folder_id)
|
||||
.where(Photo.folder_id.in_(chunk))
|
||||
.distinct()
|
||||
)).scalars().all()
|
||||
safe = [f for f in chunk if f not in set(still_used)]
|
||||
if safe:
|
||||
await session.execute(
|
||||
delete(Folder).where(Folder.id.in_(safe))
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Pruned {len(deletable_photos)} photo rows + "
|
||||
f"{len(deletable_folders)} folder rows"
|
||||
)
|
||||
key_p = "would_delete" if dry_run else "deleted"
|
||||
key_f = "would_delete_folders" if dry_run else "deleted_folders"
|
||||
return {
|
||||
key_p: len(deletable_photos),
|
||||
key_f: len(deletable_folders),
|
||||
"skipped_unmounted": len(skipped),
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"prune_missing_photos failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def prune_orphan_thumbnails(
|
||||
thumbs_root: str = "/data/thumbs",
|
||||
dry_run: bool = True,
|
||||
) -> dict:
|
||||
"""Remove `/data/thumbs/{user_id}/{photo_id}/` directories whose
|
||||
photo_id no longer exists in the photos table.
|
||||
|
||||
Layout was per-Phase-4 set up by app.tasks.thumbs and is keyed by
|
||||
`{user_id}/{photo_id}/`. The thumbs worker never deletes its own
|
||||
output on photo removal, so over the lifetime of a library these
|
||||
directories accumulate.
|
||||
|
||||
Set dry_run=False to actually `rm -rf` each matched directory.
|
||||
Returns counts of matched / removed dirs and any per-dir errors.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
if not os.path.isdir(thumbs_root):
|
||||
return {
|
||||
"would_remove": 0,
|
||||
"removed": 0,
|
||||
"skipped_no_root": True,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
live_ids = {
|
||||
row[0]
|
||||
for row in (await session.execute(select(Photo.id))).all()
|
||||
}
|
||||
|
||||
matched: list[str] = []
|
||||
errors: list[str] = []
|
||||
for user_dir in os.listdir(thumbs_root):
|
||||
user_path = os.path.join(thumbs_root, user_dir)
|
||||
if not os.path.isdir(user_path):
|
||||
continue
|
||||
for photo_dir in os.listdir(user_path):
|
||||
if photo_dir in live_ids:
|
||||
continue
|
||||
matched.append(os.path.join(user_path, photo_dir))
|
||||
|
||||
removed = 0
|
||||
if not dry_run:
|
||||
for path in matched:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
removed += 1
|
||||
except OSError as e:
|
||||
errors.append(f"{path}: {e}")
|
||||
|
||||
key = "would_remove" if dry_run else "removed"
|
||||
return {
|
||||
key: len(matched) if dry_run else removed,
|
||||
"errors": errors,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
|
||||
|
||||
async def discard_missing_photos() -> dict:
|
||||
"""Soft variant of prune_missing_photos for the periodic beat
|
||||
catch-up. Walks every active source root that is currently
|
||||
`present` (not 'renamed' — the user-driven manual flow handles
|
||||
those — and not 'unmounted'), and for each Photo whose file is
|
||||
gone from disk, sets is_discarded=True so it shows up in the
|
||||
in-app trash. Idempotent: skips photos that are already
|
||||
discarded.
|
||||
|
||||
Hard-deletion stays manual via prune_missing_photos so users
|
||||
can review the list before committing.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
sr_rows = (await session.execute(select(SourceRoot))).scalars().all()
|
||||
present_sr_ids = {
|
||||
sr.id for sr in sr_rows if _sr_state(sr.path) == 'present'
|
||||
}
|
||||
if not present_sr_ids:
|
||||
return {"discarded": 0, "checked": 0}
|
||||
|
||||
folder_to_sr = {
|
||||
fid: srid
|
||||
for fid, _path, srid in (await session.execute(
|
||||
select(Folder.id, Folder.path, Folder.source_root_id)
|
||||
)).all()
|
||||
}
|
||||
|
||||
photo_rows = (await session.execute(
|
||||
select(Photo.id, Photo.filepath, Photo.folder_id)
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
)).all()
|
||||
|
||||
missing_ids: list[str] = []
|
||||
checked = 0
|
||||
for pid, fp, folder_id in photo_rows:
|
||||
sr_id = folder_to_sr.get(folder_id)
|
||||
if sr_id not in present_sr_ids:
|
||||
continue
|
||||
checked += 1
|
||||
if not os.path.exists(fp):
|
||||
missing_ids.append(pid)
|
||||
|
||||
if missing_ids:
|
||||
CHUNK = 500
|
||||
now = datetime.utcnow()
|
||||
for i in range(0, len(missing_ids), CHUNK):
|
||||
await session.execute(
|
||||
update(Photo)
|
||||
.where(Photo.id.in_(missing_ids[i:i + CHUNK]))
|
||||
.values(is_discarded=True, discarded_at=now)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"discard_missing_photos: discarded {len(missing_ids)} "
|
||||
f"of {checked} photos under {len(present_sr_ids)} present source roots"
|
||||
)
|
||||
|
||||
return {"discarded": len(missing_ids), "checked": checked}
|
||||
except Exception as e:
|
||||
logger.error(f"discard_missing_photos failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def cleanup_data_integrity() -> dict:
|
||||
"""Top-level entry point. Runs the dedupe + count refresh in a single
|
||||
transaction. Returns a small summary dict for logging."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
sr_deleted = await _dedupe_source_roots(session)
|
||||
f_deleted = await _dedupe_folders(session)
|
||||
await _recompute_folder_counts(session)
|
||||
stale = await _warn_stale_source_roots(session)
|
||||
await session.commit()
|
||||
summary = {
|
||||
"source_roots_merged": sr_deleted,
|
||||
"folders_merged": f_deleted,
|
||||
"source_roots_stale": stale,
|
||||
}
|
||||
if sr_deleted or f_deleted:
|
||||
logger.info(f"Cleanup merged duplicates: {summary}")
|
||||
return summary
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -1,239 +0,0 @@
|
||||
"""
|
||||
Folder/filename-based date guessing and "taken_at looks wrong" detection.
|
||||
|
||||
Direct Python port of `frontend/src/lib/guessDateFromPath.ts` — the logic
|
||||
must stay in sync because the frontend renders the suggestion hint in the
|
||||
info panel while the backend owns the `has_date_warning` flag that the
|
||||
filter bar queries. Any heuristic change has to be applied to both files.
|
||||
|
||||
The guesser walks a filepath, tries the filename first as the source of
|
||||
truth, then falls back to folder segments (deepest first) and multi-
|
||||
segment layouts. Returns ``None`` when no recognisable date can be
|
||||
extracted. `has_date_warning()` compares the guess to a stored `taken_at`
|
||||
and reports whether the difference is large enough to flag.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
Source = Literal["folder", "filename"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DateGuess:
|
||||
date: datetime
|
||||
confidence: Confidence
|
||||
matched: str
|
||||
source: Source
|
||||
|
||||
|
||||
_MIN_YEAR = 1970
|
||||
# Bump the ceiling annually via `datetime.now()` rather than a literal so
|
||||
# we don't ship a time bomb. `+1` allows near-future timestamps (cameras
|
||||
# with a slightly advanced clock at year end) without opening the door to
|
||||
# 4-digit serial numbers that happen to start with "30xx".
|
||||
def _max_year() -> int:
|
||||
return datetime.now().year + 1
|
||||
|
||||
|
||||
def _valid_year(y: int) -> bool:
|
||||
return _MIN_YEAR <= y <= _max_year()
|
||||
|
||||
|
||||
def _make_date(y: int, m: int, d: int) -> Optional[datetime]:
|
||||
if not _valid_year(y):
|
||||
return None
|
||||
if not (1 <= m <= 12):
|
||||
return None
|
||||
if not (1 <= d <= 31):
|
||||
return None
|
||||
try:
|
||||
# Noon local so downstream day-bucketing is stable across timezone
|
||||
# rounding. The frontend mirrors this.
|
||||
return datetime(y, m, d, 12, 0, 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _segments(filepath: str) -> list[str]:
|
||||
return [s for s in re.split(r"[\\/]+", filepath) if s]
|
||||
|
||||
|
||||
# Synology Photos export: `YY-MM-DD HH-MM-SS NNNN.ext`. The explicit
|
||||
# HH-MM-SS half is what makes the 2-digit year safe to trust — a random
|
||||
# digit triple won't satisfy the hour/minute/second range checks below.
|
||||
# YY is mapped to 2000+YY (this format is a recent export convention).
|
||||
_SYNOLOGY_RE = re.compile(
|
||||
r"(?<!\d)(\d{2})-(\d{2})-(\d{2})[\s_](\d{2})-(\d{2})-(\d{2})(?!\d)"
|
||||
)
|
||||
_COMPACT_RE = re.compile(r"(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)")
|
||||
_DASHED_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})[-_.](\d{1,2})(?!\d)")
|
||||
_MONTH_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})(?!\d)")
|
||||
_YEAR_RE = re.compile(r"(?<!\d)(\d{4})(?!\d)")
|
||||
_FOUR_DIGITS = re.compile(r"^\d{4}$")
|
||||
_ONE_OR_TWO = re.compile(r"^\d{1,2}$")
|
||||
|
||||
|
||||
def _guess_from_string(
|
||||
input: str,
|
||||
source: Source,
|
||||
allow_year_only: bool,
|
||||
) -> Optional[DateGuess]:
|
||||
if not input:
|
||||
return None
|
||||
|
||||
m = _SYNOLOGY_RE.search(input)
|
||||
if m:
|
||||
yy, mm, dd = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
hh, mi, ss = int(m.group(4)), int(m.group(5)), int(m.group(6))
|
||||
if hh < 24 and mi < 60 and ss < 60:
|
||||
d = _make_date(2000 + yy, mm, dd)
|
||||
if d:
|
||||
d = d.replace(hour=hh, minute=mi, second=ss)
|
||||
return DateGuess(
|
||||
date=d,
|
||||
confidence="high",
|
||||
matched=(
|
||||
f"{m.group(1)}-{m.group(2)}-{m.group(3)} "
|
||||
f"{m.group(4)}:{m.group(5)}:{m.group(6)}"
|
||||
),
|
||||
source=source,
|
||||
)
|
||||
|
||||
m = _COMPACT_RE.search(input)
|
||||
if m:
|
||||
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
if d:
|
||||
return DateGuess(
|
||||
date=d,
|
||||
confidence="high",
|
||||
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
|
||||
source=source,
|
||||
)
|
||||
|
||||
m = _DASHED_RE.search(input)
|
||||
if m:
|
||||
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
if d:
|
||||
return DateGuess(
|
||||
date=d,
|
||||
confidence="high",
|
||||
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
|
||||
source=source,
|
||||
)
|
||||
|
||||
m = _MONTH_RE.search(input)
|
||||
if m:
|
||||
d = _make_date(int(m.group(1)), int(m.group(2)), 15)
|
||||
if d:
|
||||
return DateGuess(
|
||||
date=d,
|
||||
confidence="medium",
|
||||
matched=f"{m.group(1)}-{m.group(2)}",
|
||||
source=source,
|
||||
)
|
||||
|
||||
if allow_year_only:
|
||||
m = _YEAR_RE.search(input)
|
||||
if m:
|
||||
d = _make_date(int(m.group(1)), 7, 1)
|
||||
if d:
|
||||
return DateGuess(
|
||||
date=d,
|
||||
confidence="low",
|
||||
matched=m.group(1),
|
||||
source=source,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _guess_from_folder_layout(folders: list[str]) -> Optional[DateGuess]:
|
||||
# YYYY / MM / DD
|
||||
for i in range(len(folders) - 2):
|
||||
a, b, c = folders[i], folders[i + 1], folders[i + 2]
|
||||
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b) and _ONE_OR_TWO.match(c):
|
||||
d = _make_date(int(a), int(b), int(c))
|
||||
if d:
|
||||
return DateGuess(
|
||||
date=d,
|
||||
confidence="high",
|
||||
matched=f"{a}/{b}/{c}",
|
||||
source="folder",
|
||||
)
|
||||
# YYYY / MM
|
||||
for i in range(len(folders) - 1):
|
||||
a, b = folders[i], folders[i + 1]
|
||||
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b):
|
||||
d = _make_date(int(a), int(b), 15)
|
||||
if d:
|
||||
return DateGuess(
|
||||
date=d,
|
||||
confidence="medium",
|
||||
matched=f"{a}/{b}",
|
||||
source="folder",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
_CONFIDENCE_RANK: dict[Confidence, int] = {"high": 3, "medium": 2, "low": 1}
|
||||
|
||||
|
||||
def guess_date_from_path(filepath: str) -> Optional[DateGuess]:
|
||||
"""Filename wins when it has any viable match; otherwise walk folder
|
||||
segments deepest-first and pick the strongest hit."""
|
||||
if not filepath:
|
||||
return None
|
||||
|
||||
segs = _segments(filepath)
|
||||
if not segs:
|
||||
return None
|
||||
filename = segs[-1]
|
||||
folders = segs[:-1]
|
||||
|
||||
from_filename = _guess_from_string(filename, "filename", allow_year_only=False)
|
||||
if from_filename:
|
||||
return from_filename
|
||||
|
||||
best: Optional[DateGuess] = None
|
||||
for seg in reversed(folders):
|
||||
hit = _guess_from_string(seg, "folder", allow_year_only=True)
|
||||
if not hit:
|
||||
continue
|
||||
if not best or _CONFIDENCE_RANK[hit.confidence] > _CONFIDENCE_RANK[best.confidence]:
|
||||
best = hit
|
||||
if hit.confidence == "high":
|
||||
break
|
||||
|
||||
from_layout = _guess_from_folder_layout(folders)
|
||||
if from_layout and (
|
||||
not best or _CONFIDENCE_RANK[from_layout.confidence] > _CONFIDENCE_RANK[best.confidence]
|
||||
):
|
||||
best = from_layout
|
||||
|
||||
return best
|
||||
|
||||
|
||||
_ONE_DAY = 24 * 60 * 60
|
||||
|
||||
|
||||
def has_date_warning(filepath: str, taken_at: Optional[datetime]) -> bool:
|
||||
"""True when the path-based guess disagrees with ``taken_at`` by more
|
||||
than 24h, or when ``taken_at`` is missing and the path would supply
|
||||
one. This is the authoritative flag stored on `photos.has_date_warning`
|
||||
and queried by the timeline filter."""
|
||||
guess = guess_date_from_path(filepath)
|
||||
if not guess:
|
||||
return False
|
||||
if taken_at is None:
|
||||
return True
|
||||
try:
|
||||
diff = abs((taken_at - guess.date).total_seconds())
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return diff > _ONE_DAY
|
||||
@@ -1,298 +0,0 @@
|
||||
"""
|
||||
Duplicate detection: group photos by perceptual-hash + CLIP similarity.
|
||||
|
||||
Strategy
|
||||
--------
|
||||
Two complementary signals are fused into a single grouping:
|
||||
|
||||
1. **Perceptual hash (pHash)** — 16-char hex hash from the thumbnail
|
||||
worker. Catches byte-identical copies and mild re-encodes via
|
||||
Hamming distance (threshold ≤ 6 bits out of 64).
|
||||
|
||||
2. **CLIP embedding similarity** — cosine distance over 512-d vectors
|
||||
stored in the `embeddings` table with an HNSW index. Catches
|
||||
visually similar photos even when pHash diverges (e.g. crops,
|
||||
different formats, screenshots of the same content).
|
||||
|
||||
Both signals feed a union-find structure that merges overlapping matches
|
||||
into connected components.
|
||||
|
||||
Incremental mode (default post-scan)
|
||||
-------------------------------------
|
||||
`incremental_regroup` only compares *newly added* photos (those whose
|
||||
`added_at` > watermark) against the entire library. Each new photo does:
|
||||
|
||||
- An HNSW vector similarity query: O(log N) via the index.
|
||||
- A pHash comparison against a small candidate set (same group members
|
||||
or nearby CLIP results) rather than the full N² sweep.
|
||||
|
||||
This makes the post-scan cost O(new × log N) instead of O(N²).
|
||||
|
||||
Full regroup
|
||||
------------
|
||||
`regroup_duplicates` still performs the full pairwise pHash pass +
|
||||
CLIP sweep, used for initial setup and manual re-detection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.photos import Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pHash Hamming distance threshold (6 out of 64 bits).
|
||||
DEFAULT_PHASH_THRESHOLD = 6
|
||||
|
||||
|
||||
def _hex_to_int(h: str) -> int:
|
||||
"""Parse a 16-char hex pHash to a Python int. Returns -1 on bad input
|
||||
so the pairwise loop can skip the row without raising."""
|
||||
try:
|
||||
return int(h, 16)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
|
||||
|
||||
def _hamming(a: int, b: int) -> int:
|
||||
"""Population count of XOR — the canonical hash distance metric."""
|
||||
x = a ^ b
|
||||
try:
|
||||
return x.bit_count() # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
return bin(x).count('1')
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
"""Tiny union-find / disjoint-set used to merge similar photos into
|
||||
connected components."""
|
||||
|
||||
def __init__(self, keys: list[str]) -> None:
|
||||
self._index = {k: i for i, k in enumerate(keys)}
|
||||
n = len(keys)
|
||||
self.parent = list(range(n))
|
||||
self.rank = [0] * n
|
||||
|
||||
def find(self, x: int) -> int:
|
||||
while self.parent[x] != x:
|
||||
self.parent[x] = self.parent[self.parent[x]]
|
||||
x = self.parent[x]
|
||||
return x
|
||||
|
||||
def union_by_key(self, key_a: str, key_b: str) -> None:
|
||||
ia, ib = self._index.get(key_a), self._index.get(key_b)
|
||||
if ia is None or ib is None:
|
||||
return
|
||||
ra, rb = self.find(ia), self.find(ib)
|
||||
if ra == rb:
|
||||
return
|
||||
if self.rank[ra] < self.rank[rb]:
|
||||
ra, rb = rb, ra
|
||||
self.parent[rb] = ra
|
||||
if self.rank[ra] == self.rank[rb]:
|
||||
self.rank[ra] += 1
|
||||
|
||||
def components(self, keys: list[str]) -> dict[int, list[str]]:
|
||||
"""Return {root_idx: [photo_ids...]} for groups of size >= 2."""
|
||||
groups: dict[int, list[str]] = {}
|
||||
for key in keys:
|
||||
idx = self._index[key]
|
||||
root = self.find(idx)
|
||||
groups.setdefault(root, []).append(key)
|
||||
return {r: members for r, members in groups.items() if len(members) >= 2}
|
||||
|
||||
|
||||
async def regroup_duplicates(
|
||||
phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
|
||||
**_ignored,
|
||||
) -> dict:
|
||||
"""Full recompute of duplicate groups using pHash similarity.
|
||||
|
||||
Idempotent — safe to call as often as you like. Returns a summary dict.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Pull all visible photos with a phash or embedding.
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Photo.id, Photo.phash)
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
.where(Photo.is_hidden.is_(False))
|
||||
)
|
||||
).all()
|
||||
|
||||
if not rows:
|
||||
await _clear_all_groups(session)
|
||||
await session.commit()
|
||||
return {'photos_considered': 0, 'groups': 0, 'members': 0}
|
||||
|
||||
ids = [row[0] for row in rows]
|
||||
phash_map = {row[0]: _hex_to_int(row[1]) for row in rows if row[1]}
|
||||
|
||||
uf = _UnionFind(ids)
|
||||
|
||||
# ── Phase 1: pHash pairwise (O(N²) on photos with phash) ──
|
||||
phash_ids = [pid for pid in ids if pid in phash_map]
|
||||
phash_vals = [phash_map[pid] for pid in phash_ids]
|
||||
n = len(phash_ids)
|
||||
for i in range(n):
|
||||
hi = phash_vals[i]
|
||||
if hi < 0:
|
||||
continue
|
||||
for j in range(i + 1, n):
|
||||
hj = phash_vals[j]
|
||||
if hj < 0:
|
||||
continue
|
||||
if _hamming(hi, hj) <= phash_threshold:
|
||||
uf.union_by_key(phash_ids[i], phash_ids[j])
|
||||
|
||||
# ── Write results ──
|
||||
await _clear_all_groups(session)
|
||||
|
||||
groups = uf.components(ids)
|
||||
groups_created = 0
|
||||
members_total = 0
|
||||
for member_ids in groups.values():
|
||||
group_id = str(uuid.uuid4())
|
||||
await session.execute(
|
||||
update(Photo)
|
||||
.where(Photo.id.in_(member_ids))
|
||||
.values(duplicate_group_id=group_id, is_duplicate=True)
|
||||
)
|
||||
groups_created += 1
|
||||
members_total += len(member_ids)
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"regroup_duplicates: {len(ids)} photos, "
|
||||
f"{groups_created} group(s), {members_total} member(s)"
|
||||
)
|
||||
return {
|
||||
'photos_considered': len(ids),
|
||||
'groups': groups_created,
|
||||
'members': members_total,
|
||||
}
|
||||
|
||||
|
||||
async def incremental_regroup(
|
||||
since: Optional[datetime] = None,
|
||||
phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
|
||||
**_ignored,
|
||||
) -> dict:
|
||||
"""Incremental duplicate detection for newly added photos using pHash."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# If no watermark, fall back to full regroup.
|
||||
if since is None:
|
||||
# Find the most recent scan start by looking at the newest
|
||||
# photo that already has a duplicate_group_id check completed.
|
||||
# As a simple heuristic, use photos added in the last hour.
|
||||
from datetime import timedelta
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
|
||||
# Photo.added_at is stored as TIMESTAMP WITHOUT TIME ZONE, so
|
||||
# asyncpg rejects aware datetimes with "can't subtract offset-naive
|
||||
# and offset-aware". Normalise: if `since` has a tzinfo, convert
|
||||
# it to UTC and drop the tzinfo so the bind parameter is naive.
|
||||
if since.tzinfo is not None:
|
||||
since = since.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Get newly added photos (the "new" set).
|
||||
new_rows = (
|
||||
await session.execute(
|
||||
select(Photo.id, Photo.phash)
|
||||
.where(Photo.added_at >= since)
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
.where(Photo.is_hidden.is_(False))
|
||||
)
|
||||
).all()
|
||||
|
||||
if not new_rows:
|
||||
return {'photos_considered': 0, 'new_photos': 0, 'groups_updated': 0, 'members_added': 0}
|
||||
|
||||
new_ids = [r[0] for r in new_rows]
|
||||
new_phash = {r[0]: _hex_to_int(r[1]) for r in new_rows if r[1]}
|
||||
|
||||
# Get ALL existing photos for union-find (we need to merge into
|
||||
# existing groups).
|
||||
all_rows = (
|
||||
await session.execute(
|
||||
select(Photo.id, Photo.phash, Photo.duplicate_group_id)
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
.where(Photo.is_hidden.is_(False))
|
||||
)
|
||||
).all()
|
||||
|
||||
all_ids = [r[0] for r in all_rows]
|
||||
all_phash = {r[0]: _hex_to_int(r[1]) for r in all_rows if r[1]}
|
||||
existing_groups: dict[str, str] = {
|
||||
r[0]: r[2] for r in all_rows if r[2]
|
||||
}
|
||||
|
||||
uf = _UnionFind(all_ids)
|
||||
|
||||
# Pre-seed existing groups into the union-find so we merge into
|
||||
# them rather than creating parallel groups.
|
||||
group_to_members: dict[str, list[str]] = {}
|
||||
for pid, gid in existing_groups.items():
|
||||
group_to_members.setdefault(gid, []).append(pid)
|
||||
for members in group_to_members.values():
|
||||
for i in range(1, len(members)):
|
||||
uf.union_by_key(members[0], members[i])
|
||||
|
||||
# ── Phase 1: pHash — compare each new photo against ALL photos ──
|
||||
for new_id in new_ids:
|
||||
nh = new_phash.get(new_id, -1)
|
||||
if nh < 0:
|
||||
continue
|
||||
for existing_id, eh in all_phash.items():
|
||||
if existing_id == new_id or eh < 0:
|
||||
continue
|
||||
if _hamming(nh, eh) <= phash_threshold:
|
||||
uf.union_by_key(new_id, existing_id)
|
||||
|
||||
# ── Write results ──
|
||||
# Only update groups that contain at least one new photo.
|
||||
# Clear all groups first, then rewrite.
|
||||
await _clear_all_groups(session)
|
||||
|
||||
groups = uf.components(all_ids)
|
||||
groups_created = 0
|
||||
members_total = 0
|
||||
new_in_groups = 0
|
||||
for member_ids in groups.values():
|
||||
group_id = str(uuid.uuid4())
|
||||
await session.execute(
|
||||
update(Photo)
|
||||
.where(Photo.id.in_(member_ids))
|
||||
.values(duplicate_group_id=group_id, is_duplicate=True)
|
||||
)
|
||||
groups_created += 1
|
||||
members_total += len(member_ids)
|
||||
if any(m in new_ids for m in member_ids):
|
||||
new_in_groups += len([m for m in member_ids if m in new_ids])
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"incremental_regroup: {len(new_ids)} new photos, "
|
||||
f"{groups_created} group(s), {new_in_groups} new member(s) grouped"
|
||||
)
|
||||
return {
|
||||
'photos_considered': len(all_ids),
|
||||
'new_photos': len(new_ids),
|
||||
'groups_updated': groups_created,
|
||||
'members_added': new_in_groups,
|
||||
}
|
||||
|
||||
|
||||
async def _clear_all_groups(session) -> None:
|
||||
"""Reset duplicate_group_id / is_duplicate on every photo."""
|
||||
await session.execute(
|
||||
update(Photo).values(duplicate_group_id=None, is_duplicate=False)
|
||||
)
|
||||
@@ -1,72 +0,0 @@
|
||||
"""
|
||||
EXIF write-back helpers.
|
||||
|
||||
The rest of the app reads EXIF at scan time and stashes the result in Postgres
|
||||
(see `services/metadata.py`). This module handles the reverse direction: when
|
||||
the user corrects a date in the UI we also rewrite the relevant EXIF tags on
|
||||
disk so a later rescan won't clobber the fix and external tools see the same
|
||||
truth the DB does.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXIFTOOL_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class ExifWriteError(RuntimeError):
|
||||
"""Raised when exiftool fails to write tags to a file."""
|
||||
|
||||
|
||||
def _format_exif_dt(dt: datetime) -> str:
|
||||
return dt.strftime("%Y:%m:%d %H:%M:%S")
|
||||
|
||||
|
||||
async def write_taken_at(filepath: str, dt: datetime) -> None:
|
||||
"""Rewrite DateTimeOriginal / CreateDate / ModifyDate on the file.
|
||||
|
||||
- ``-overwrite_original`` so we don't litter the library with
|
||||
``<name>_original`` sidecars.
|
||||
- ``-P`` preserves the file's mtime so the scanner's mtime-based
|
||||
change detection stays quiet.
|
||||
- We set all three common date tags together because different viewers
|
||||
read different ones; keeping them in lockstep avoids confusing
|
||||
downstream tools and our own re-extraction pass.
|
||||
"""
|
||||
if not Path(filepath).exists():
|
||||
raise ExifWriteError(f"File not found: {filepath}")
|
||||
|
||||
stamp = _format_exif_dt(dt)
|
||||
cmd = [
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
"-P",
|
||||
f"-DateTimeOriginal={stamp}",
|
||||
f"-CreateDate={stamp}",
|
||||
f"-ModifyDate={stamp}",
|
||||
filepath,
|
||||
]
|
||||
|
||||
def _run() -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=EXIFTOOL_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_run)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ExifWriteError(f"exiftool timed out writing {filepath}") from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise ExifWriteError("exiftool binary not available") from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
msg = (result.stderr or result.stdout or "unknown error").strip()
|
||||
logger.warning("exiftool write failed for %s: %s", filepath, msg)
|
||||
raise ExifWriteError(msg)
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Gravatar URL helper.
|
||||
|
||||
Pure function — no HTTP calls. The browser does the actual image
|
||||
fetch. We just build the deterministic URL from the user's email and
|
||||
let Gravatar serve an identicon when no account exists for that hash,
|
||||
so the avatar is never a broken image.
|
||||
|
||||
Current Gravatar guidance is SHA-256 of the trimmed, lower-cased email.
|
||||
MD5 still works but is deprecated, so we prefer SHA-256.
|
||||
"""
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def gravatar_url(email: Optional[str], size: int = 240) -> Optional[str]:
|
||||
"""Return a Gravatar image URL for `email`, or None when email is empty.
|
||||
|
||||
The `d=identicon` fallback guarantees a deterministic placeholder when
|
||||
the address has no Gravatar account, so callers can treat the result
|
||||
as a valid image URL.
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
normalized = email.strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
return f"https://gravatar.com/avatar/{digest}?d=identicon&s={size}"
|
||||
@@ -1,536 +0,0 @@
|
||||
"""
|
||||
Metadata extraction service using ExifTool
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo
|
||||
from app.services.date_guess import has_date_warning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_exif_datetime(date_str: str) -> Optional[datetime]:
|
||||
"""Parse EXIF datetime string to Python datetime.
|
||||
|
||||
Returns a tz-naive datetime — the photos.taken_at column is
|
||||
`timestamp without time zone`. Tz-aware inputs (e.g. SubSec
|
||||
fields with `+02:00` or QuickTime UTC `Z`) are converted to UTC
|
||||
and stripped. Cameras that wrote the all-zero placeholder
|
||||
return None.
|
||||
"""
|
||||
if not date_str:
|
||||
return None
|
||||
s = str(date_str).strip()
|
||||
# All-zero placeholder some cameras emit when the clock isn't set.
|
||||
if s.startswith("0000:00:00") or s.startswith("0000-00-00"):
|
||||
return None
|
||||
|
||||
formats = [
|
||||
"%Y:%m:%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y:%m:%d %H:%M:%S.%f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
# Tz-aware variants: SubSecDateTimeOriginal often looks like
|
||||
# "2023:11:30 14:30:45.123+02:00", QuickTime CreateDate as
|
||||
# "2023:11:30 14:30:45Z" or with offsets.
|
||||
"%Y:%m:%d %H:%M:%S%z",
|
||||
"%Y:%m:%d %H:%M:%S.%f%z",
|
||||
"%Y-%m-%dT%H:%M:%S%z",
|
||||
"%Y-%m-%dT%H:%M:%S.%f%z",
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
dt = datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
if dt.tzinfo is not None:
|
||||
from datetime import timezone
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
return None
|
||||
|
||||
_DMS_RE = re.compile(
|
||||
r"""\s*
|
||||
(?P<deg>-?\d+(?:\.\d+)?)\s*(?:deg|°|d)?\s*
|
||||
(?:(?P<min>\d+(?:\.\d+)?)\s*[\'’m]?\s*)?
|
||||
(?:(?P<sec>\d+(?:\.\d+)?)\s*[\"”s]?\s*)?
|
||||
(?P<ref>[NSEW])?\s*$""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_coord(value, ref: str | None) -> float | None:
|
||||
"""Coerce a single GPS coordinate from any form ExifTool may emit.
|
||||
|
||||
ExifTool's ``-j`` JSON output applies print conversion by default, so
|
||||
coordinates can come back as:
|
||||
|
||||
* a number (``48.1278``) — happens for some sources / when ``-n`` is set
|
||||
* a plain DMS string (``"48 deg 7' 39.96\\""``) — bare ``EXIF:GPSLatitude``
|
||||
* a DMS-with-ref string (``"48 deg 7' 39.96\\" N"``) — ``Composite:GPSLatitude``
|
||||
|
||||
The optional ``ref`` argument lets the caller pass an explicit
|
||||
``GPSLatitudeRef`` / ``GPSLongitudeRef`` ('N'/'S'/'E'/'W') when the
|
||||
string itself doesn't carry one. Returns signed decimal degrees, or
|
||||
``None`` if the value is unparseable.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# Numeric path — already decimal degrees, possibly already signed.
|
||||
if isinstance(value, (int, float)):
|
||||
out = float(value)
|
||||
else:
|
||||
m = _DMS_RE.match(str(value))
|
||||
if not m:
|
||||
return None
|
||||
deg = float(m.group('deg'))
|
||||
minutes = float(m.group('min') or 0)
|
||||
seconds = float(m.group('sec') or 0)
|
||||
out = abs(deg) + minutes / 60.0 + seconds / 3600.0
|
||||
if deg < 0:
|
||||
out = -out
|
||||
embedded_ref = m.group('ref')
|
||||
if embedded_ref:
|
||||
ref = embedded_ref
|
||||
if ref:
|
||||
r = ref[0].upper()
|
||||
if r in ('S', 'W'):
|
||||
out = -abs(out)
|
||||
elif r in ('N', 'E'):
|
||||
out = abs(out)
|
||||
return out
|
||||
|
||||
|
||||
def extract_gps(exif_data: Dict) -> tuple:
|
||||
"""Return (lat, lon) in signed decimal degrees, or (None, None).
|
||||
|
||||
With ``exiftool -G -j`` GPS values are keyed under their group.
|
||||
``Composite:GPSLatitude`` / ``Composite:GPSLongitude`` carry the
|
||||
hemisphere reference inline (``"48 deg 7' 39.96\\" N"``) while the bare
|
||||
``EXIF:GPSLatitude`` / ``EXIF:GPSLongitude`` need the separate
|
||||
``EXIF:GPSLatitudeRef`` / ``EXIF:GPSLongitudeRef`` to know the sign.
|
||||
|
||||
Pre-fix this function read the *unprefixed* keys ``GPSLatitude`` /
|
||||
``GPSLongitude`` (which never exist in ``-G`` output) AND assumed
|
||||
they were already floats — so it silently dropped every photo's GPS.
|
||||
"""
|
||||
lat = _parse_coord(exif_data.get('Composite:GPSLatitude'), None)
|
||||
lon = _parse_coord(exif_data.get('Composite:GPSLongitude'), None)
|
||||
if lat is None or lon is None:
|
||||
lat = _parse_coord(
|
||||
exif_data.get('EXIF:GPSLatitude'),
|
||||
exif_data.get('EXIF:GPSLatitudeRef'),
|
||||
)
|
||||
lon = _parse_coord(
|
||||
exif_data.get('EXIF:GPSLongitude'),
|
||||
exif_data.get('EXIF:GPSLongitudeRef'),
|
||||
)
|
||||
if lat is None or lon is None:
|
||||
return None, None
|
||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||
return None, None
|
||||
# Some cameras emit (0, 0) when they have no GPS lock — treat as missing
|
||||
if lat == 0 and lon == 0:
|
||||
return None, None
|
||||
return lat, lon
|
||||
|
||||
|
||||
def extract_key_metadata(exif_data: Dict) -> Dict:
|
||||
"""Extract key metadata fields for FTS indexing"""
|
||||
key_fields = []
|
||||
|
||||
# Camera information
|
||||
if 'EXIF:Make' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Make'])
|
||||
if 'EXIF:Model' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Model'])
|
||||
if 'EXIF:LensModel' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:LensModel'])
|
||||
|
||||
# Location information
|
||||
lat, lon = extract_gps(exif_data)
|
||||
if lat is not None and lon is not None:
|
||||
key_fields.append(f"GPS: {lat}, {lon}")
|
||||
|
||||
# IPTC/XMP keywords
|
||||
keywords = exif_data.get('IPTC:Keywords') or exif_data.get('XMP:Subject')
|
||||
if keywords:
|
||||
if isinstance(keywords, list):
|
||||
key_fields.extend(keywords)
|
||||
else:
|
||||
key_fields.append(keywords)
|
||||
|
||||
# Copyright and creator
|
||||
if 'EXIF:Copyright' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Copyright'])
|
||||
if 'XMP:Creator' in exif_data:
|
||||
key_fields.append(exif_data['XMP:Creator'])
|
||||
if 'EXIF:Artist' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Artist'])
|
||||
|
||||
return {
|
||||
'exif_text': ' '.join(str(f) for f in key_fields),
|
||||
'camera_make': exif_data.get('EXIF:Make'),
|
||||
'camera_model': exif_data.get('EXIF:Model'),
|
||||
'lens_model': exif_data.get('EXIF:LensModel'),
|
||||
'gps_latitude': lat,
|
||||
'gps_longitude': lon,
|
||||
}
|
||||
|
||||
@shared_task(name='extract_metadata')
|
||||
def extract_metadata(photo_id: str):
|
||||
"""Extract metadata from a photo using ExifTool"""
|
||||
return asyncio.run(_extract_metadata_async(photo_id))
|
||||
|
||||
def _apply_memories_metadata(photo: Photo, data: dict) -> None:
|
||||
"""Apply a Memories API `/image/info/{id}` response to a Photo row.
|
||||
|
||||
Replicates the side-effects of the ExifTool path (width, height,
|
||||
latitude, longitude, taken_at, taken_at_source, has_date_warning,
|
||||
exif_json) without spawning a subprocess. Mule's date-fallback chain
|
||||
(SubSec → DateTimeOriginal → CreateDate → MediaCreateDate → path)
|
||||
is preserved — Memories itself only stores the resolved datetaken
|
||||
and we still need to honour `taken_at_source='manual'` and recover
|
||||
filename-encoded dates for archive photos that lack EXIF.
|
||||
|
||||
The frontend PhotoInfoPanel reads `Make`/`Model`/`ISO`/`FNumber`
|
||||
out of `exif_json`. Memories' `exif` dict uses those exact plain
|
||||
key names (no `EXIF:` prefix), so storing it directly keeps the
|
||||
info panel working without a format adapter.
|
||||
"""
|
||||
from app.services.date_guess import guess_date_from_path
|
||||
|
||||
exif: Dict = data.get('exif') or {}
|
||||
|
||||
# Dimensions
|
||||
w = data.get('w')
|
||||
h = data.get('h')
|
||||
if w:
|
||||
photo.width = int(w)
|
||||
if h:
|
||||
photo.height = int(h)
|
||||
|
||||
# GPS — Memories stores plain decimal-degree values in the exif
|
||||
# dict (no DMS/composite parsing needed).
|
||||
gps_lat = exif.get('GPSLatitude')
|
||||
gps_lon = exif.get('GPSLongitude')
|
||||
if isinstance(gps_lat, (int, float)) and isinstance(gps_lon, (int, float)):
|
||||
photo.latitude = float(gps_lat)
|
||||
photo.longitude = float(gps_lon)
|
||||
else:
|
||||
# Memories omits GPS when not present; clear cleanly.
|
||||
photo.latitude = None
|
||||
photo.longitude = None
|
||||
|
||||
# Store the EXIF dict for the info panel + full-text search.
|
||||
photo.exif_json = json.dumps(exif)
|
||||
|
||||
# Date extraction — only when the user hasn't pinned it manually.
|
||||
if photo.taken_at_source != 'manual':
|
||||
date_fields = [
|
||||
'SubSecDateTimeOriginal',
|
||||
'DateTimeOriginal',
|
||||
'CreateDate',
|
||||
'MediaCreateDate',
|
||||
'TrackCreateDate',
|
||||
]
|
||||
new_taken_at = None
|
||||
for field in date_fields:
|
||||
val = exif.get(field)
|
||||
if not val:
|
||||
continue
|
||||
parsed = parse_exif_datetime(val)
|
||||
if parsed:
|
||||
new_taken_at = parsed
|
||||
photo.taken_at = parsed
|
||||
photo.taken_at_source = 'exif'
|
||||
break
|
||||
if new_taken_at is None:
|
||||
# Filename / folder fallback — same heuristic as the
|
||||
# ExifTool path uses for stripped JPEGs and archive scans.
|
||||
guess = guess_date_from_path(photo.filepath)
|
||||
if guess is not None:
|
||||
photo.taken_at = guess.date
|
||||
photo.taken_at_source = 'path'
|
||||
|
||||
photo.has_date_warning = has_date_warning(photo.filepath, photo.taken_at)
|
||||
|
||||
|
||||
async def _extract_metadata_async(photo_id: str):
|
||||
"""Async implementation of metadata extraction.
|
||||
|
||||
Primary path: Memories' HTTP API (~1-2 ms per photo, no
|
||||
subprocess). Falls back to ExifTool when Memories returns 404
|
||||
(file not yet indexed by NC's scan) or any non-success response.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
# Get photo from database
|
||||
result = await session.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
logger.error(f"Photo not found: {photo_id}")
|
||||
return {'status': 'error', 'message': 'Photo not found'}
|
||||
|
||||
# Resolve the owner once — we need it for both the fileid
|
||||
# lookup and the Memories API call.
|
||||
owner = None
|
||||
if photo.user_id:
|
||||
from app.models.user import User
|
||||
owner = (
|
||||
await session.execute(
|
||||
select(User).where(User.id == photo.user_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
# Backfill nextcloud_fileid if missing (same behaviour as
|
||||
# before — the thumb handler depends on this column).
|
||||
if (
|
||||
photo.nextcloud_fileid is None
|
||||
and owner is not None
|
||||
and owner.nextcloud_app_password_enc
|
||||
):
|
||||
from app.services.nextcloud_dav import (
|
||||
fetch_fileid, is_nextcloud_path,
|
||||
)
|
||||
if photo.filepath and is_nextcloud_path(photo.filepath):
|
||||
try:
|
||||
fid = fetch_fileid(owner, photo.filepath)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"fileid lookup failed for %s: %s", photo_id, e
|
||||
)
|
||||
fid = None
|
||||
if fid is not None:
|
||||
photo.nextcloud_fileid = fid
|
||||
|
||||
# Primary path: ask Memories for the metadata it has
|
||||
# already extracted. Replaces a ~80–100 ms ExifTool
|
||||
# subprocess with a single ~1–2 ms HTTP call.
|
||||
if (
|
||||
photo.nextcloud_fileid is not None
|
||||
and owner is not None
|
||||
and owner.nextcloud_app_password_enc
|
||||
):
|
||||
from app.services.nextcloud_dav import (
|
||||
fetch_memories_info_async,
|
||||
)
|
||||
memories_data = None
|
||||
try:
|
||||
memories_data = await fetch_memories_info_async(
|
||||
owner, photo.nextcloud_fileid
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Memories info call failed for %s: %s",
|
||||
photo_id, e,
|
||||
)
|
||||
if memories_data:
|
||||
_apply_memories_metadata(photo, memories_data)
|
||||
photo.processing_status = 'completed'
|
||||
photo.processing_error = None
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Metadata extracted via Memories for photo {photo_id}"
|
||||
)
|
||||
return {
|
||||
'status': 'success',
|
||||
'source': 'memories',
|
||||
'photo_id': photo_id,
|
||||
'taken_at': (
|
||||
photo.taken_at.isoformat() if photo.taken_at else None
|
||||
),
|
||||
}
|
||||
logger.info(
|
||||
"Memories had no info for fileid %s; falling back to ExifTool",
|
||||
photo.nextcloud_fileid,
|
||||
)
|
||||
|
||||
# Fallback path: ExifTool subprocess. Used when Memories
|
||||
# hasn't indexed the file yet (brand-new uploads racing the
|
||||
# NC scan), or for non-NC photos that bypass the Memories
|
||||
# pipeline entirely.
|
||||
|
||||
# Check if file exists
|
||||
if not Path(photo.filepath).exists():
|
||||
logger.error(f"File not found: {photo.filepath}")
|
||||
return {'status': 'error', 'message': 'File not found'}
|
||||
|
||||
# Run ExifTool to extract metadata
|
||||
cmd = [
|
||||
'exiftool',
|
||||
'-j', # JSON output
|
||||
'-G', # Group names
|
||||
'-s', # Short output format
|
||||
'-All', # All metadata
|
||||
photo.filepath
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"ExifTool error: {result.stderr}")
|
||||
photo.processing_error = f"ExifTool: {result.stderr[:500]}"
|
||||
await session.commit()
|
||||
return {'status': 'error', 'message': result.stderr}
|
||||
|
||||
# Parse JSON output
|
||||
metadata = json.loads(result.stdout)
|
||||
if metadata and len(metadata) > 0:
|
||||
exif_data = metadata[0]
|
||||
|
||||
# Store full metadata as JSON
|
||||
photo.exif_json = json.dumps(exif_data)
|
||||
|
||||
# Extract taken_at date — but only if the user hasn't
|
||||
# explicitly set it via the UI. Manual edits are the
|
||||
# source of truth and must survive any rescan.
|
||||
if photo.taken_at_source != 'manual':
|
||||
# Trusted EXIF fields, in order of preference.
|
||||
# SubSecDateTimeOriginal includes sub-second
|
||||
# precision and often a tz offset, so it's the
|
||||
# most accurate when present. ModifyDate is NOT
|
||||
# in this list — it's set every time the file
|
||||
# is re-saved (Lightroom export, EXIF strip,
|
||||
# batch resize) and routinely overwrote correct
|
||||
# capture dates with edit-time dates.
|
||||
date_fields = [
|
||||
'EXIF:SubSecDateTimeOriginal',
|
||||
'EXIF:DateTimeOriginal',
|
||||
'EXIF:CreateDate',
|
||||
'QuickTime:MediaCreateDate',
|
||||
'QuickTime:CreateDate',
|
||||
]
|
||||
|
||||
new_taken_at = None
|
||||
for field in date_fields:
|
||||
if field in exif_data:
|
||||
parsed = parse_exif_datetime(exif_data[field])
|
||||
if parsed:
|
||||
new_taken_at = parsed
|
||||
photo.taken_at = parsed
|
||||
photo.taken_at_source = 'exif'
|
||||
break
|
||||
|
||||
# Fallback: if the file has no trusted EXIF date,
|
||||
# try to extract one from the filename / folder
|
||||
# path. The same date_guess module powers the
|
||||
# has_date_warning flag — reusing it here means
|
||||
# photos without EXIF (scanned prints, stripped
|
||||
# JPEGs, re-saved exports) get a sensible date
|
||||
# instead of falling back to filesystem mtime
|
||||
# (which on Nextcloud-mounted files is just the
|
||||
# upload time).
|
||||
if new_taken_at is None:
|
||||
from app.services.date_guess import guess_date_from_path
|
||||
guess = guess_date_from_path(photo.filepath)
|
||||
if guess is not None:
|
||||
photo.taken_at = guess.date
|
||||
photo.taken_at_source = 'path'
|
||||
|
||||
# Re-run the path-vs-date heuristic now that we know
|
||||
# whether EXIF provided a real capture date. A true EXIF
|
||||
# date that matches the folder clears the warning the
|
||||
# scanner set during the filesystem-mtime pass.
|
||||
photo.has_date_warning = has_date_warning(
|
||||
photo.filepath, photo.taken_at
|
||||
)
|
||||
|
||||
# Extract dimensions if not already set
|
||||
if not photo.width:
|
||||
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
|
||||
if not photo.height:
|
||||
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
|
||||
|
||||
# Extract GPS coordinates into first-class columns so the
|
||||
# Map view can query them without parsing exif_json.
|
||||
lat, lon = extract_gps(exif_data)
|
||||
photo.latitude = lat
|
||||
photo.longitude = lon
|
||||
|
||||
# Extract and store key metadata for search
|
||||
key_metadata = extract_key_metadata(exif_data)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Metadata extracted for photo {photo_id}")
|
||||
return {
|
||||
'status': 'success',
|
||||
'photo_id': photo_id,
|
||||
'taken_at': photo.taken_at.isoformat() if photo.taken_at else None
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"ExifTool timeout for {photo.filepath}")
|
||||
photo.processing_error = 'ExifTool timeout'
|
||||
await session.commit()
|
||||
return {'status': 'error', 'message': 'ExifTool timeout'}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse ExifTool output: {e}")
|
||||
photo.processing_error = f"Invalid ExifTool output: {e}"
|
||||
await session.commit()
|
||||
return {'status': 'error', 'message': 'Invalid ExifTool output'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting metadata for {photo_id}: {e}")
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
|
||||
@shared_task(name='backfill_taken_at')
|
||||
def backfill_taken_at():
|
||||
"""Re-enqueue extract_metadata for every non-manual photo.
|
||||
|
||||
Used after fixing the date-extraction logic (removing ModifyDate
|
||||
fallback, adding path-based fallback) to re-derive taken_at across
|
||||
the whole library without touching photos the user has manually
|
||||
corrected. Each enqueued task is fast (~90ms) and runs on the
|
||||
default queue; ~21k photos finish in ~15 min on the existing
|
||||
worker-light concurrency.
|
||||
"""
|
||||
return asyncio.run(_backfill_taken_at_async())
|
||||
|
||||
|
||||
async def _backfill_taken_at_async():
|
||||
from sqlalchemy import or_
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Photo.id).where(
|
||||
# NULL taken_at_source predates the column default and
|
||||
# should still be re-extracted; only 'manual' is sacred.
|
||||
or_(
|
||||
Photo.taken_at_source != 'manual',
|
||||
Photo.taken_at_source.is_(None),
|
||||
),
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
)
|
||||
photo_ids = [row[0] for row in result.all()]
|
||||
|
||||
for pid in photo_ids:
|
||||
extract_metadata.delay(pid)
|
||||
|
||||
logger.info(f"backfill_taken_at: queued extract_metadata for {len(photo_ids)} photos")
|
||||
return {'queued': len(photo_ids)}
|
||||
@@ -1,512 +0,0 @@
|
||||
"""Nextcloud WebDAV client — only the verbs we actually need.
|
||||
|
||||
Outgoing mutations (upload, delete, rename/move) on files that live
|
||||
under a user's Nextcloud-rooted SourceRoot route through this client
|
||||
instead of touching the filesystem directly. That way Nextcloud's
|
||||
oc_filecache, trashbin, sharing/comments metadata, and desktop sync
|
||||
clients all stay coherent — the price of bypassing it is a stale
|
||||
Nextcloud and resurrected files when sync clients re-upload.
|
||||
|
||||
Reads (scanning, hashing, EXIF, ML pipelines) keep using the bind
|
||||
mount at NEXTCLOUD_USERS_ROOT. WebDAV is far too slow for every byte
|
||||
of every photo, and the read side has no consistency cost — Nextcloud
|
||||
is the writer, the bind mount is the reader, that's it.
|
||||
|
||||
Auth: HTTP Basic with the user's Nextcloud app password (set via the
|
||||
Settings UI, stored Fernet-encrypted at rest). OIDC bearer reuse is a
|
||||
later optimization; app passwords work today and are well-supported.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import BinaryIO, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.services.secrets import decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Top-level mount inside the backend container. The Nextcloud user tree
|
||||
# `/mnt/library/homecloud/<nc_user>/files/...` shows up here as
|
||||
# `/nextcloud-users/<nc_user>/files/...`.
|
||||
NEXTCLOUD_USERS_ROOT = os.environ.get("NEXTCLOUD_USERS_ROOT", "/nextcloud-users")
|
||||
|
||||
|
||||
def is_nextcloud_path(path: str) -> bool:
|
||||
"""True iff `path` resolves under the configured NC users mount."""
|
||||
if not path:
|
||||
return False
|
||||
norm = os.path.normpath(path)
|
||||
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
|
||||
return norm == root or norm.startswith(root + os.sep)
|
||||
|
||||
|
||||
def split_nextcloud_path(path: str) -> Tuple[str, str]:
|
||||
"""Return (nc_username, rel_path) for a file/dir under the NC mount.
|
||||
|
||||
rel_path is the path relative to `<NEXTCLOUD_USERS_ROOT>/<user>/files/`,
|
||||
suitable for appending to the WebDAV base URL. Raises if `path`
|
||||
isn't a Nextcloud-rooted path or doesn't sit under a `files/`
|
||||
directory.
|
||||
"""
|
||||
norm = os.path.normpath(path)
|
||||
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
|
||||
if not (norm == root or norm.startswith(root + os.sep)):
|
||||
raise ValueError(f"Not a Nextcloud-rooted path: {path!r}")
|
||||
rest = norm[len(root):].lstrip(os.sep) # "<user>/files/foo/bar.jpg"
|
||||
parts = rest.split(os.sep, 2)
|
||||
if len(parts) < 3 or parts[1] != "files":
|
||||
# Either we got just /<user>, /<user>/files (no rel), or a
|
||||
# different second segment — only the user's `files/` tree is
|
||||
# safe to mutate via WebDAV.
|
||||
if len(parts) == 2 and parts[1] == "files":
|
||||
return parts[0], ""
|
||||
raise ValueError(
|
||||
f"Path doesn't live under <user>/files/: {path!r}"
|
||||
)
|
||||
return parts[0], parts[2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class NextcloudCredentialsMissing(HTTPException):
|
||||
"""The user hasn't set their Nextcloud app password yet, but the
|
||||
request needs it to mutate a Nextcloud-managed file. 412 because
|
||||
the precondition (credentials) is missing rather than the request
|
||||
itself being malformed."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||
detail=(
|
||||
"Set your Nextcloud app password in Settings → Library "
|
||||
"before mutating files in your Nextcloud library."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _credentials_for(user: User) -> tuple[str, str]:
|
||||
"""Resolve the (nc_username, app_password) pair for a user.
|
||||
Raises NextcloudCredentialsMissing when either is missing."""
|
||||
nc_user = (user.nextcloud_username or "").strip()
|
||||
app_pw = decrypt(user.nextcloud_app_password_enc)
|
||||
if not nc_user or not app_pw:
|
||||
raise NextcloudCredentialsMissing()
|
||||
return nc_user, app_pw
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
"""The Nextcloud WebDAV base URL (without trailing slash, without
|
||||
user-suffixed path). Resolved per-call so a config reload picks up
|
||||
a new value without restarting workers."""
|
||||
base = (
|
||||
os.environ.get("NEXTCLOUD_BASE_URL")
|
||||
or getattr(settings, "nextcloud_base_url", None)
|
||||
or ""
|
||||
).rstrip("/")
|
||||
if not base:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="NEXTCLOUD_BASE_URL is not configured on the backend",
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
def _dav_url(nc_username: str, rel_path: str) -> str:
|
||||
"""Compose the absolute WebDAV URL for a relative path under the
|
||||
user's `files/` collection."""
|
||||
base = _base_url()
|
||||
rel = (rel_path or "").lstrip("/")
|
||||
# Each segment must be URL-encoded. httpx encodes path segments at
|
||||
# request time, so we hand it the raw join — but we explicitly drop
|
||||
# `..` traversals here as defense in depth.
|
||||
if any(seg in ("", "..") for seg in rel.split("/") if seg):
|
||||
raise HTTPException(status_code=400, detail="Invalid relative path")
|
||||
parts = [base, "remote.php/dav/files", nc_username]
|
||||
if rel:
|
||||
parts.append(rel)
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
# httpx Client TTL: short, since a single request is the unit of work.
|
||||
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
|
||||
def _client(auth: tuple[str, str]) -> httpx.Client:
|
||||
return httpx.Client(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
|
||||
|
||||
|
||||
def _async_client(auth: tuple[str, str]) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
|
||||
|
||||
|
||||
# Pooled async client for the read-heavy NC endpoints (preview proxy,
|
||||
# Memories info). Auth is per-user, so it's passed at call time via
|
||||
# `auth=BasicAuth(...)`; the pool itself is auth-less. Keepalive +
|
||||
# HTTP/2 cuts the TCP+TLS handshake from every thumbnail request and
|
||||
# multiplexes the dozens of concurrent grid fetches over one socket.
|
||||
_PREVIEW_LIMITS = httpx.Limits(
|
||||
max_connections=64, max_keepalive_connections=32, keepalive_expiry=120.0,
|
||||
)
|
||||
_preview_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def init_preview_client() -> None:
|
||||
"""Called from the FastAPI lifespan startup hook."""
|
||||
global _preview_client
|
||||
if _preview_client is None:
|
||||
_preview_client = httpx.AsyncClient(
|
||||
timeout=_TIMEOUT,
|
||||
limits=_PREVIEW_LIMITS,
|
||||
http2=True,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
async def close_preview_client() -> None:
|
||||
"""Called from the FastAPI lifespan shutdown hook."""
|
||||
global _preview_client
|
||||
if _preview_client is not None:
|
||||
await _preview_client.aclose()
|
||||
_preview_client = None
|
||||
|
||||
|
||||
def _shared_preview_client() -> httpx.AsyncClient:
|
||||
"""Return the pooled client. Falls back to a one-shot AsyncClient if
|
||||
init wasn't called (tests, scripts) — caller must aclose it."""
|
||||
if _preview_client is not None:
|
||||
return _preview_client
|
||||
return httpx.AsyncClient(
|
||||
timeout=_TIMEOUT, http2=True, follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def _raise_for_dav(resp: httpx.Response, action: str) -> None:
|
||||
"""Translate Nextcloud WebDAV errors into FastAPI HTTPExceptions
|
||||
the frontend can show. We surface Nextcloud's body verbatim when
|
||||
it's small enough, since it tends to carry the actually-useful
|
||||
detail (quota, permission denied, etc.)."""
|
||||
if resp.is_success:
|
||||
return
|
||||
body = resp.text or ""
|
||||
if len(body) > 400:
|
||||
body = body[:400] + "…"
|
||||
logger.warning("Nextcloud %s failed: %s %s — %s", action, resp.status_code, resp.reason_phrase, body[:200])
|
||||
if resp.status_code in (401, 403):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Nextcloud rejected the {action}: {resp.reason_phrase}. "
|
||||
f"Check your app password under Settings → Library.",
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Not found in Nextcloud during {action}")
|
||||
if resp.status_code == 507:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail="Nextcloud quota exceeded",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Nextcloud error during {action}: {resp.status_code} {resp.reason_phrase}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verbs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def delete_for_user(user: User, abs_path: str) -> None:
|
||||
"""WebDAV DELETE — moves the file/dir into the user's NC trashbin.
|
||||
`abs_path` is the absolute filesystem path under the bind mount."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
url = _dav_url(nc_user, rel)
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request("DELETE", url)
|
||||
# 204 = deleted. 404 = already gone (treat as success, idempotent).
|
||||
if resp.status_code == 404:
|
||||
logger.info("Nextcloud DELETE %s already gone, treating as success", rel)
|
||||
return
|
||||
_raise_for_dav(resp, "delete")
|
||||
|
||||
|
||||
def move_for_user(user: User, src_abs: str, dst_abs: str) -> None:
|
||||
"""WebDAV MOVE — rename or move within the same Nextcloud user."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
src_user, src_rel = split_nextcloud_path(src_abs)
|
||||
dst_user, dst_rel = split_nextcloud_path(dst_abs)
|
||||
if src_user != nc_user or dst_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="MOVE across Nextcloud users is not supported",
|
||||
)
|
||||
src_url = _dav_url(nc_user, src_rel)
|
||||
dst_url = _dav_url(nc_user, dst_rel)
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request(
|
||||
"MOVE",
|
||||
src_url,
|
||||
headers={"Destination": dst_url, "Overwrite": "F"},
|
||||
)
|
||||
_raise_for_dav(resp, "move")
|
||||
|
||||
|
||||
def mkcol_for_user(user: User, abs_path: str) -> None:
|
||||
"""WebDAV MKCOL — create a directory. Idempotent: a 405 (Method Not
|
||||
Allowed) means the collection already exists, treat as success."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
url = _dav_url(nc_user, rel)
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request("MKCOL", url)
|
||||
if resp.status_code == 405:
|
||||
return
|
||||
_raise_for_dav(resp, "mkcol")
|
||||
|
||||
|
||||
def put_for_user(
|
||||
user: User,
|
||||
abs_path: str,
|
||||
fileobj: BinaryIO,
|
||||
content_type: Optional[str] = None,
|
||||
) -> None:
|
||||
"""WebDAV PUT — upload `fileobj` to `abs_path`. Caller is
|
||||
responsible for ensuring intermediate collections exist via
|
||||
`mkcol_for_user`. Streams the body, no in-memory copy."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
url = _dav_url(nc_user, rel)
|
||||
headers = {}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request("PUT", url, content=fileobj, headers=headers)
|
||||
_raise_for_dav(resp, "upload")
|
||||
|
||||
|
||||
def ensure_parents_for_user(user: User, abs_path: str) -> None:
|
||||
"""Walk the parent chain of `abs_path` under the user's NC root and
|
||||
`mkcol` any missing collection. Stops at the user's `files/`
|
||||
directory — never tries to create that, which is owned by Nextcloud
|
||||
itself."""
|
||||
nc_user, _ = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
if not rel:
|
||||
return
|
||||
parts = rel.split("/")
|
||||
if len(parts) <= 1:
|
||||
return # no intermediate dirs to make
|
||||
accum: list[str] = []
|
||||
for seg in parts[:-1]:
|
||||
accum.append(seg)
|
||||
sub_rel = "/".join(accum)
|
||||
sub_abs = os.path.join(NEXTCLOUD_USERS_ROOT, nc_user, "files", sub_rel)
|
||||
mkcol_for_user(user, sub_abs)
|
||||
|
||||
|
||||
_FILEID_PROPFIND = (
|
||||
b'<?xml version="1.0"?>'
|
||||
b'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
|
||||
b'<d:prop><oc:fileid/></d:prop>'
|
||||
b'</d:propfind>'
|
||||
)
|
||||
|
||||
|
||||
def fetch_fileid(user: User, abs_path: str) -> Optional[int]:
|
||||
"""Look up Nextcloud's numeric fileid for the file at `abs_path`.
|
||||
|
||||
`abs_path` is the absolute filesystem path under the bind mount,
|
||||
e.g. `/nextcloud-users/admin/files/Photos/2024/01/foo.jpg`. Returns
|
||||
None when the file isn't under a Nextcloud-rooted tree, the user
|
||||
has no app password set, or Nextcloud returns 404 — callers should
|
||||
treat None as "skip this row" rather than an error.
|
||||
|
||||
Used by `scripts/backfill_nextcloud_fileid.py`. The hot path (the
|
||||
thumbnail handler) reads `Photo.nextcloud_fileid` directly so it
|
||||
doesn't round-trip to Nextcloud per request.
|
||||
"""
|
||||
if not is_nextcloud_path(abs_path):
|
||||
return None
|
||||
try:
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
except NextcloudCredentialsMissing:
|
||||
return None
|
||||
try:
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
except ValueError:
|
||||
return None
|
||||
if expected_user != nc_user:
|
||||
return None
|
||||
url = _dav_url(nc_user, rel)
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request(
|
||||
"PROPFIND",
|
||||
url,
|
||||
headers={"Depth": "0", "Content-Type": "application/xml"},
|
||||
content=_FILEID_PROPFIND,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
if not resp.is_success:
|
||||
logger.warning(
|
||||
"Nextcloud PROPFIND %s returned %s", rel, resp.status_code
|
||||
)
|
||||
return None
|
||||
import re as _re
|
||||
m = _re.search(rb"<oc:fileid>(\d+)</oc:fileid>", resp.content)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def get_preview_bytes(
|
||||
user: User, fileid: int, x: int, y: int
|
||||
) -> Optional[bytes]:
|
||||
"""Sync sibling of `get_preview_async` for callers in non-async
|
||||
contexts.
|
||||
|
||||
Returns the preview body on success, None on 404 / non-success /
|
||||
missing credentials. Caller is expected to feed the bytes into
|
||||
PIL or similar.
|
||||
"""
|
||||
try:
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
except NextcloudCredentialsMissing:
|
||||
return None
|
||||
url = f"{_base_url()}/index.php/core/preview"
|
||||
params = {
|
||||
"fileId": str(fileid),
|
||||
"x": str(x),
|
||||
"y": str(y),
|
||||
"a": "true",
|
||||
"forceIcon": "false",
|
||||
}
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.get(url, params=params)
|
||||
if resp.status_code == 404 or not resp.is_success:
|
||||
return None
|
||||
return resp.content
|
||||
|
||||
|
||||
async def fetch_memories_info_async(
|
||||
user: User, fileid: int
|
||||
) -> Optional[dict]:
|
||||
"""Fetch the Memories app's per-file metadata blob.
|
||||
|
||||
`GET /index.php/apps/memories/api/image/info/{fileid}` returns
|
||||
Memories' pre-extracted view of the file: `w`, `h`, `datetaken`
|
||||
(unix epoch), `mtime`, `mimetype`, `size`, plus an `exif` dict
|
||||
of plain-named EXIF fields (Make, Model, ISO, FNumber,
|
||||
DateTimeOriginal, GPSLatitude, GPSLongitude, etc.). The endpoint
|
||||
is `#[NoAdminRequired] #[PublicPage]` but CSRF-checked, so we
|
||||
send `OCS-APIRequest: true` to bypass the check the same way
|
||||
OCS API clients do.
|
||||
|
||||
Returns None on 404 (file not yet indexed by Memories, or
|
||||
fileid stale) or any non-success response — callers should fall
|
||||
back to ExifTool extraction in that case.
|
||||
"""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
url = (
|
||||
f"{_base_url()}/index.php/apps/memories/api/image/info/{int(fileid)}"
|
||||
)
|
||||
client = _shared_preview_client()
|
||||
try:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"OCS-APIRequest": "true",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
auth=httpx.BasicAuth(nc_user, app_pw),
|
||||
)
|
||||
finally:
|
||||
# Only close if we got a one-shot fallback client; the pooled
|
||||
# one is owned by the lifespan hook.
|
||||
if client is not _preview_client:
|
||||
await client.aclose()
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
if not resp.is_success:
|
||||
logger.warning(
|
||||
"Memories info for fileid %s returned %s", fileid, resp.status_code
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("Memories info parse failed for fileid %s: %s", fileid, e)
|
||||
return None
|
||||
|
||||
|
||||
async def get_preview_async(
|
||||
user: User, fileid: int, x: int, y: int
|
||||
) -> httpx.Response:
|
||||
"""Fetch a Nextcloud preview for `fileid` sized up to (x, y).
|
||||
|
||||
Nextcloud's `/index.php/core/preview` endpoint returns a JPEG (or
|
||||
icon fallback) sized so the longest edge fits within the requested
|
||||
box. `a=true` preserves the source aspect ratio; `forceIcon=false`
|
||||
makes it 404 rather than returning a placeholder if no real preview
|
||||
can be produced.
|
||||
|
||||
Auth uses the user's encrypted app password — same path as every
|
||||
other mutation in this module. The caller streams the body back
|
||||
to the frontend; we don't buffer the bytes here.
|
||||
"""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
url = f"{_base_url()}/index.php/core/preview"
|
||||
params = {
|
||||
"fileId": str(fileid),
|
||||
"x": str(x),
|
||||
"y": str(y),
|
||||
"a": "true",
|
||||
"forceIcon": "false",
|
||||
}
|
||||
client = _shared_preview_client()
|
||||
try:
|
||||
return await client.get(
|
||||
url, params=params, auth=httpx.BasicAuth(nc_user, app_pw),
|
||||
)
|
||||
finally:
|
||||
# Only close if we got a one-shot fallback client; the pooled
|
||||
# one is owned by the lifespan hook.
|
||||
if client is not _preview_client:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
def whoami_dir_exists(nc_username: str) -> bool:
|
||||
"""True iff the bind-mounted `<NEXTCLOUD_USERS_ROOT>/<user>/files`
|
||||
directory exists. Used by the UI to validate the override field
|
||||
without round-tripping to Nextcloud — the bind mount is enough to
|
||||
confirm Nextcloud actually has that user."""
|
||||
if not nc_username or "/" in nc_username or nc_username in (".", ".."):
|
||||
return False
|
||||
target = os.path.join(NEXTCLOUD_USERS_ROOT, nc_username, "files")
|
||||
return os.path.isdir(target)
|
||||
@@ -1,93 +0,0 @@
|
||||
"""
|
||||
Scanner service for initial library scan and per-user source root bootstrap.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import SourceRoot
|
||||
from app.models.user import User
|
||||
from app.tasks.scan import scan_all_source_roots
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def bootstrap_user_source_root(user: User, session=None) -> None:
|
||||
"""Create the media directory and a source root for a user.
|
||||
|
||||
Called when a new user is created (by the admin or the setup endpoint).
|
||||
If the user already has a source root, this is a no-op.
|
||||
"""
|
||||
own_session = session is None
|
||||
if own_session:
|
||||
session = AsyncSessionLocal()
|
||||
|
||||
try:
|
||||
# Check if user already has a source root
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.user_id == user.id)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
return
|
||||
|
||||
os.makedirs(user.media_path, exist_ok=True)
|
||||
|
||||
source_root = SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=user.media_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
session.add(source_root)
|
||||
if own_session:
|
||||
await session.commit()
|
||||
else:
|
||||
await session.flush()
|
||||
|
||||
logger.info(
|
||||
f"Bootstrapped source root for user '{user.username}': "
|
||||
f"{user.media_path}"
|
||||
)
|
||||
finally:
|
||||
if own_session:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def bootstrap_default_source_root() -> None:
|
||||
"""Legacy bootstrap — for existing installs that have source roots
|
||||
without user_id (pre-auth migration). On fresh installs, source roots
|
||||
are created per-user via bootstrap_user_source_root. If there are
|
||||
already source roots in the DB, this is a no-op.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SourceRoot))
|
||||
if result.scalars().first() is not None:
|
||||
return # Already have source roots.
|
||||
|
||||
# No source roots and no users means fresh install — the setup
|
||||
# endpoint will create the first user + source root.
|
||||
user_count = (await session.execute(
|
||||
select(User)
|
||||
)).scalars().first()
|
||||
if user_count is None:
|
||||
logger.info(
|
||||
"No users or source roots — waiting for first-run setup."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def start_initial_scan():
|
||||
"""Start the initial library scan.
|
||||
|
||||
The watchfiles-based watcher has been retired in favour of Nextcloud
|
||||
`webhook_listeners` (see `app.routers.nc_webhook`). NC POSTs every
|
||||
file event directly to mule, so we no longer keep a long-running
|
||||
inotify task. The periodic `discard_missing_photos_beat` Celery job
|
||||
is still there as a safety net for deletions a webhook might miss.
|
||||
"""
|
||||
try:
|
||||
scan_all_source_roots.delay()
|
||||
logger.info("Initial scan queued successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start initial scan: {e}")
|
||||
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
FTS search over photos.search_vector with optional tag/date filters.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def hybrid_search(
|
||||
db: AsyncSession,
|
||||
q: Optional[str] = None,
|
||||
tag_ids: Optional[list[str]] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Full-text search using photos.search_vector. No embeddings, no OCR."""
|
||||
if q:
|
||||
try:
|
||||
fts_stmt = text("""
|
||||
SELECT id, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank
|
||||
FROM photos
|
||||
WHERE search_vector @@ plainto_tsquery('english', :q)
|
||||
AND is_trashed = false
|
||||
AND is_hidden = false
|
||||
ORDER BY rank DESC
|
||||
LIMIT 500
|
||||
""")
|
||||
rows = (await db.execute(fts_stmt, {"q": q})).fetchall()
|
||||
except Exception as e:
|
||||
logger.warning("FTS search failed: %s", e)
|
||||
rows = []
|
||||
|
||||
scored = [(pid, float(rank)) for pid, rank in rows]
|
||||
|
||||
if tag_ids:
|
||||
from app.models.tags import photo_tags
|
||||
photo_ids = [pid for pid, _ in scored]
|
||||
if not photo_ids:
|
||||
return []
|
||||
stmt = select(photo_tags.c.photo_id).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
).distinct()
|
||||
valid = {row[0] for row in (await db.execute(stmt)).fetchall()}
|
||||
scored = [(pid, s) for pid, s in scored if pid in valid]
|
||||
|
||||
page = scored[offset : offset + limit]
|
||||
return [{"photo_id": pid, "score": s} for pid, s in page]
|
||||
|
||||
# No text query — recent photos with tag/date filters.
|
||||
if tag_ids:
|
||||
from app.models.tags import photo_tags
|
||||
sub = select(photo_tags.c.photo_id).where(
|
||||
photo_tags.c.tag_id.in_(tag_ids)
|
||||
).distinct().subquery()
|
||||
stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id)
|
||||
else:
|
||||
stmt = select(Photo.id)
|
||||
stmt = stmt.where(
|
||||
Photo.is_discarded.is_(False),
|
||||
Photo.is_hidden.is_(False),
|
||||
)
|
||||
if date_from:
|
||||
stmt = stmt.where(Photo.taken_at >= date_from)
|
||||
if date_to:
|
||||
stmt = stmt.where(Photo.taken_at <= date_to)
|
||||
stmt = stmt.order_by(Photo.added_at.desc()).offset(offset).limit(limit)
|
||||
rows = (await db.execute(stmt)).fetchall()
|
||||
return [{"photo_id": row[0], "score": 0.0} for row in rows]
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Symmetric encryption for credentials we have to store.
|
||||
|
||||
Used today for the per-user Nextcloud app password — we need the
|
||||
plaintext to put it in an outgoing HTTP Basic header, so a one-way
|
||||
hash won't do. Key is derived from `settings.secret_key` via SHA-256
|
||||
so existing deployments don't need a separate KMS dance, and a stable
|
||||
SECRET_KEY rotates these credentials automatically.
|
||||
|
||||
Fernet is symmetric AES-128-CBC + HMAC-SHA256 with a versioned
|
||||
ciphertext envelope; good enough for column-level secrecy in a
|
||||
single-host homelab. Rotate by setting a new SECRET_KEY and asking
|
||||
users to re-enter their app password.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
# Fernet requires a 32-byte url-safe base64 key. SHA-256 of the
|
||||
# configured secret gives us exactly 32 bytes; b64-urlsafe-encode
|
||||
# to fit the API contract.
|
||||
digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Return a base64 token that can be stored in a VARCHAR column."""
|
||||
return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(token: Optional[str]) -> Optional[str]:
|
||||
"""Inverse of encrypt. Returns None for None / empty input. Raises
|
||||
on tampered or wrong-key tokens — callers should treat that as
|
||||
"credential unset" rather than crashing the request."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return _fernet().decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken:
|
||||
return None
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Shared video helpers used by the /playback endpoint and the
|
||||
pretranscode celery task.
|
||||
|
||||
The actual ffmpeg/ffprobe work is sync (subprocess.run); FastAPI
|
||||
handlers wrap calls in asyncio.to_thread, celery just calls them
|
||||
directly. Keeping a single sync implementation avoids drift between
|
||||
the request-time fallback and the background pre-transcode."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# On-disk cache of browser-playable transcodes. Backed by /data which is
|
||||
# the persistent volume in mulita-backend / mulita-worker-light.
|
||||
VIDEO_CACHE_DIR = Path('/data/video-cache')
|
||||
VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Codecs the browser can play in <video> across Chrome / Firefox / Safari
|
||||
# without re-encoding. h264 covers everything practical we have on disk;
|
||||
# add av1 / vp9 here if the source library ever picks those up.
|
||||
PLAYBACK_OK_VCODECS = {'h264', 'avc1'}
|
||||
|
||||
# Container extensions that we trust to passthrough when the codec is
|
||||
# OK. .mov is intentionally excluded — Chrome and Firefox refuse to
|
||||
# play even h264-in-mov reliably, so .mov always goes through the cache.
|
||||
PLAYBACK_OK_EXTS = {'.mp4', '.m4v', '.webm'}
|
||||
|
||||
|
||||
def cache_path_for(photo_id: str) -> Path:
|
||||
"""Where the transcoded MP4 lives for a given photo id."""
|
||||
return VIDEO_CACHE_DIR / f'{photo_id}.mp4'
|
||||
|
||||
|
||||
def ffprobe_video_codec(path: str) -> Optional[str]:
|
||||
"""Return the video stream's codec_name (lowercased) or None on
|
||||
probe failure. ~50ms for typical files."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
path,
|
||||
],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("ffprobe failed for %s: %s", path, e)
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"ffprobe rc=%s for %s: %s",
|
||||
result.returncode, path, (result.stderr or '').strip()[:200],
|
||||
)
|
||||
return None
|
||||
return ((result.stdout or '').strip().lower()) or None
|
||||
|
||||
|
||||
def needs_transcode(src_path: str) -> bool:
|
||||
"""True when /playback would have to encode rather than passthrough.
|
||||
Uses extension first (cheap), only ffprobes when the container is
|
||||
plausibly web-safe."""
|
||||
ext = Path(src_path).suffix.lower()
|
||||
if ext not in PLAYBACK_OK_EXTS:
|
||||
return True
|
||||
return ffprobe_video_codec(src_path) not in PLAYBACK_OK_VCODECS
|
||||
|
||||
|
||||
def transcode_to_h264_mp4(src: str, dst: str, *, timeout: int = 3600) -> bool:
|
||||
"""Transcode `src` to H.264 8-bit MP4 at `dst`. Returns True on
|
||||
success.
|
||||
|
||||
-pix_fmt yuv420p forces 8-bit output so 10-bit HEVC sources still
|
||||
play on browsers without 10-bit decode. Audio is always re-encoded
|
||||
to AAC because iPhone 16 ships APAC audio that browsers can't
|
||||
decode, and the audio pass is cheap next to the video pass.
|
||||
+faststart relocates the moov atom so progressive playback works.
|
||||
|
||||
Atomic publish via tmp + os.replace so a failed run never leaves a
|
||||
half-written .mp4 in the cache. -f mp4 forces the muxer because the
|
||||
.tmp suffix isn't a format hint ffmpeg recognises."""
|
||||
tmp = dst + '.tmp'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
'ffmpeg', '-y', '-loglevel', 'error',
|
||||
'-i', src,
|
||||
'-map', '0:v:0',
|
||||
'-map', '0:a:0?',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-crf', '23',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '160k',
|
||||
'-movflags', '+faststart',
|
||||
'-f', 'mp4',
|
||||
tmp,
|
||||
],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.error("ffmpeg failed for %s: %s", src, e)
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
"ffmpeg rc=%s for %s: %s",
|
||||
result.returncode, src, (result.stderr or '').strip()[:500],
|
||||
)
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
try:
|
||||
os.replace(tmp, dst)
|
||||
except OSError as e:
|
||||
logger.error("failed to publish transcoded %s: %s", dst, e)
|
||||
return False
|
||||
return True
|
||||
@@ -1,15 +0,0 @@
|
||||
"""
|
||||
Celery tasks module
|
||||
"""
|
||||
from app.tasks.celery import celery_app
|
||||
from app.tasks.scan import scan_folder, scan_all_source_roots, watch_folders
|
||||
from app.tasks.thumbs import generate_thumbnails, regenerate_all_thumbnails
|
||||
|
||||
__all__ = [
|
||||
'celery_app',
|
||||
'scan_folder',
|
||||
'scan_all_source_roots',
|
||||
'watch_folders',
|
||||
'generate_thumbnails',
|
||||
'regenerate_all_thumbnails'
|
||||
]
|
||||
@@ -1,66 +0,0 @@
|
||||
"""
|
||||
Celery configuration and app initialization
|
||||
"""
|
||||
import logging
|
||||
|
||||
from celery import Celery
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
celery_app = Celery(
|
||||
'mulita',
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
include=[
|
||||
'app.tasks.scan',
|
||||
'app.tasks.thumbs',
|
||||
'app.tasks.video',
|
||||
'app.services.metadata',
|
||||
]
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
timezone='UTC',
|
||||
enable_utc=True,
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
task_soft_time_limit=300,
|
||||
task_time_limit=600,
|
||||
task_routes={
|
||||
'generate_thumbnails': {'queue': 'high'},
|
||||
'regenerate_all_thumbnails': {'queue': 'high'},
|
||||
'backfill_phashes': {'queue': 'high'},
|
||||
'regroup_duplicates': {'queue': 'high'},
|
||||
'incremental_regroup_duplicates': {'queue': 'high'},
|
||||
'scan_folder': {'queue': 'low'},
|
||||
'scan_all_source_roots': {'queue': 'low'},
|
||||
'backfill_gps': {'queue': 'low'},
|
||||
# CPU-heavy but tolerant of the low-priority queue (doesn't block
|
||||
# any user-facing flow).
|
||||
'pretranscode_video': {'queue': 'low'},
|
||||
# `watch_folders` is retired (file events come from NC webhooks)
|
||||
# but the task definition still exists as a no-op shim for any
|
||||
# in-flight apply_async. Route it to the default queue so the
|
||||
# remaining worker actually drains it.
|
||||
'watch_folders': {'queue': 'default'},
|
||||
'discard_missing_photos_beat': {'queue': 'low'},
|
||||
},
|
||||
task_default_queue='default',
|
||||
task_default_exchange='default',
|
||||
task_default_exchange_type='direct',
|
||||
task_default_routing_key='default',
|
||||
broker_connection_retry_on_startup=True,
|
||||
# Periodic catch-up so external file deletions in Nextcloud get
|
||||
# reflected even when the real-time watcher missed the event
|
||||
# (worker restart window, mount transient, etc).
|
||||
beat_schedule={
|
||||
'discard-missing-photos-every-30min': {
|
||||
'task': 'discard_missing_photos_beat',
|
||||
'schedule': 30 * 60,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,749 +0,0 @@
|
||||
"""
|
||||
Celery tasks for scanning folders and indexing photos
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import aiofiles
|
||||
import redis
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo, Folder, SourceRoot
|
||||
from app.config import settings
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
from app.tasks.video import pretranscode_video
|
||||
from app.services.metadata import extract_metadata
|
||||
from app.services.date_guess import has_date_warning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Redis keys read by GET /api/v1/library/scan/status. The frontend
|
||||
# ScanProgress widget polls that endpoint, so anything we want to surface
|
||||
# in the UI lives here.
|
||||
REDIS_KEY_ACTIVE = 'scan:active'
|
||||
REDIS_KEY_CURRENT_FOLDER = 'scan:current_folder'
|
||||
REDIS_KEY_PROCESSED = 'scan:processed_files'
|
||||
REDIS_KEY_TOTAL = 'scan:total_files'
|
||||
REDIS_KEY_ERRORS = 'scan:errors'
|
||||
MAX_ERROR_ENTRIES = 50 # cap the errors list so a noisy scan doesn't blow Redis
|
||||
|
||||
|
||||
def _get_redis():
|
||||
"""Connect to the broker for progress writes. Returns None on failure
|
||||
so a Redis outage doesn't prevent the scan itself from running."""
|
||||
try:
|
||||
return redis.Redis.from_url(settings.celery_broker_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not reach Redis for scan progress: {e}")
|
||||
return None
|
||||
|
||||
# Supported file extensions
|
||||
PHOTO_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp'}
|
||||
RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw'}
|
||||
HEIC_EXTENSIONS = {'.heic', '.heif'}
|
||||
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv'}
|
||||
|
||||
SUPPORTED_EXTENSIONS = PHOTO_EXTENSIONS | RAW_EXTENSIONS | HEIC_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
def get_media_type(filepath: str) -> str:
|
||||
"""Determine media type from file extension"""
|
||||
ext = Path(filepath).suffix.lower()
|
||||
if ext in PHOTO_EXTENSIONS:
|
||||
return 'photo'
|
||||
elif ext in RAW_EXTENSIONS:
|
||||
return 'raw'
|
||||
elif ext in HEIC_EXTENSIONS:
|
||||
return 'heic'
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
return 'video'
|
||||
return 'unknown'
|
||||
|
||||
async def calculate_file_hash(filepath: str) -> str:
|
||||
"""Calculate SHA-256 hash of a file"""
|
||||
hash_sha256 = hashlib.sha256()
|
||||
try:
|
||||
async with aiofiles.open(filepath, 'rb') as f:
|
||||
while chunk := await f.read(8192):
|
||||
hash_sha256.update(chunk)
|
||||
return hash_sha256.hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {filepath}: {e}")
|
||||
return ""
|
||||
|
||||
@shared_task(bind=True, name='scan_folder')
|
||||
def scan_folder(self, folder_path: str, source_root_id: Optional[str] = None):
|
||||
"""
|
||||
Scan a folder and index all photos/videos
|
||||
"""
|
||||
# Run async function in sync context
|
||||
return asyncio.run(_scan_folder_async(folder_path, source_root_id, self))
|
||||
|
||||
async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], task):
|
||||
"""Async implementation of folder scanning. Writes progress to Redis so
|
||||
GET /api/v1/library/scan/status can surface it to the frontend
|
||||
ScanProgress widget."""
|
||||
logger.info(f"Starting scan of folder: {folder_path}")
|
||||
|
||||
r = _get_redis()
|
||||
|
||||
PROGRESS_TTL = 3600 # 1 hour — auto-expire if scan crashes
|
||||
|
||||
def progress_set(key: str, value) -> None:
|
||||
if r is None:
|
||||
return
|
||||
try:
|
||||
r.set(key, str(value), ex=PROGRESS_TTL)
|
||||
except Exception as e:
|
||||
logger.debug(f"scan progress set failed: {e}")
|
||||
|
||||
def progress_push_error(message: str) -> None:
|
||||
if r is None:
|
||||
return
|
||||
try:
|
||||
r.lpush(REDIS_KEY_ERRORS, message)
|
||||
r.ltrim(REDIS_KEY_ERRORS, 0, MAX_ERROR_ENTRIES - 1)
|
||||
except Exception as e:
|
||||
logger.debug(f"scan progress push_error failed: {e}")
|
||||
|
||||
# Mark scan active immediately so the UI starts polling fast.
|
||||
progress_set(REDIS_KEY_ACTIVE, 'true')
|
||||
progress_set(REDIS_KEY_CURRENT_FOLDER, folder_path)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
# Get or create source root
|
||||
if not source_root_id:
|
||||
source_root = await get_or_create_source_root(session, folder_path)
|
||||
source_root_id = source_root.id
|
||||
else:
|
||||
source_root = (await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == source_root_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Inherit user_id from the source root's owner
|
||||
owner_user_id = source_root.user_id if source_root else None
|
||||
|
||||
# Per-scan memoization cache for "is this folder's effective
|
||||
# is_hidden true?" Populated on first lookup by walking the
|
||||
# parent_id chain up to the source root. Keyed by folder_id
|
||||
# so repeated photos in the same folder pay only one lookup.
|
||||
hidden_folder_cache: dict[str, bool] = {}
|
||||
|
||||
async def is_folder_effectively_hidden(folder_row: Folder) -> bool:
|
||||
if folder_row.id in hidden_folder_cache:
|
||||
return hidden_folder_cache[folder_row.id]
|
||||
# Walk parents. If the current folder is hidden, short-
|
||||
# circuit. Otherwise climb until we hit a root (no
|
||||
# parent_id) or a cached ancestor.
|
||||
if folder_row.is_hidden:
|
||||
hidden_folder_cache[folder_row.id] = True
|
||||
return True
|
||||
parent_id = folder_row.parent_id
|
||||
while parent_id is not None:
|
||||
if parent_id in hidden_folder_cache:
|
||||
hidden_folder_cache[folder_row.id] = hidden_folder_cache[parent_id]
|
||||
return hidden_folder_cache[folder_row.id]
|
||||
parent = (
|
||||
await session.execute(
|
||||
select(Folder).where(Folder.id == parent_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if parent is None:
|
||||
break
|
||||
if parent.is_hidden:
|
||||
hidden_folder_cache[folder_row.id] = True
|
||||
return True
|
||||
parent_id = parent.parent_id
|
||||
hidden_folder_cache[folder_row.id] = False
|
||||
return False
|
||||
|
||||
# Pre-walk to compute the total file count upfront. Without this
|
||||
# the progress bar would jump every time a new subfolder is
|
||||
# encountered because the running total kept growing.
|
||||
total_files = 0
|
||||
for _root, _dirs, files in os.walk(folder_path):
|
||||
total_files += sum(
|
||||
1 for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||
)
|
||||
progress_set(REDIS_KEY_TOTAL, total_files)
|
||||
progress_set(REDIS_KEY_PROCESSED, 0)
|
||||
|
||||
processed_files = 0
|
||||
errors = []
|
||||
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
# Get or create folder entry
|
||||
folder = await get_or_create_folder(session, root, source_root_id, owner_user_id)
|
||||
progress_set(REDIS_KEY_CURRENT_FOLDER, root)
|
||||
|
||||
# Filter supported files
|
||||
supported_files = [f for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS]
|
||||
|
||||
# Process files in batches
|
||||
batch_size = settings.scanner.batch_size
|
||||
for i in range(0, len(supported_files), batch_size):
|
||||
batch = supported_files[i:i + batch_size]
|
||||
# Defer task dispatch until AFTER commit so workers don't
|
||||
# query for rows that aren't visible to other sessions yet.
|
||||
pending_dispatch: list[str] = []
|
||||
pending_video_pretranscode: list[tuple[str, str]] = []
|
||||
|
||||
for filename in batch:
|
||||
filepath = os.path.join(root, filename)
|
||||
|
||||
try:
|
||||
# Check if file already exists in database
|
||||
existing = await session.execute(
|
||||
select(Photo).where(Photo.filepath == filepath)
|
||||
)
|
||||
existing_photo = existing.scalar_one_or_none()
|
||||
if existing_photo is not None:
|
||||
# Resurrect a previously-discarded row only
|
||||
# when the file's mtime is newer than
|
||||
# discarded_at. Bare existence on disk isn't
|
||||
# proof the user changed their mind: every
|
||||
# backend boot fires scan_all_source_roots,
|
||||
# which used to walk every file and silently
|
||||
# un-discard the lot. The mtime check still
|
||||
# covers the legitimate flows (WebDAV DELETE
|
||||
# + re-upload, trashbin restore via PUT-
|
||||
# overwrite, any "I removed it then put it
|
||||
# back") because those rewrite the file and
|
||||
# bump mtime past the discard time. Rows
|
||||
# with discarded_at IS NULL (legacy) are
|
||||
# left alone — preserve user intent over
|
||||
# best-effort cleanup.
|
||||
if existing_photo.is_discarded:
|
||||
discarded_at = existing_photo.discarded_at
|
||||
try:
|
||||
mtime = os.path.getmtime(filepath)
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
file_modified_after_discard = (
|
||||
discarded_at is not None
|
||||
and mtime
|
||||
> discarded_at.replace(
|
||||
tzinfo=timezone.utc
|
||||
).timestamp()
|
||||
)
|
||||
if file_modified_after_discard:
|
||||
existing_photo.is_discarded = False
|
||||
existing_photo.discarded_at = None
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Resurrected discarded photo "
|
||||
f"(file modified after discard): "
|
||||
f"{filepath}"
|
||||
)
|
||||
extract_metadata.delay(existing_photo.id)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping discarded photo "
|
||||
f"(file unchanged since discard): "
|
||||
f"{filepath}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"File already indexed: {filepath}")
|
||||
processed_files += 1
|
||||
progress_set(REDIS_KEY_PROCESSED, processed_files)
|
||||
continue
|
||||
|
||||
# Get file stats
|
||||
stat = os.stat(filepath)
|
||||
|
||||
# Calculate file hash for duplicate detection
|
||||
file_hash = await calculate_file_hash(filepath)
|
||||
|
||||
# Check for duplicate by hash. We only care
|
||||
# whether *any* other photo shares this hash, so
|
||||
# use a count rather than scalar_one_or_none()
|
||||
# which raises "Multiple rows were found" the
|
||||
# moment the library has 2+ copies of the same
|
||||
# file (i.e. exactly the case we're trying to
|
||||
# flag).
|
||||
is_dup = False
|
||||
if file_hash:
|
||||
dup_count = (await session.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
Photo.file_hash == file_hash
|
||||
)
|
||||
)).scalar() or 0
|
||||
is_dup = dup_count > 0
|
||||
|
||||
# Inherit the effective-hidden flag from the
|
||||
# folder's ancestry. If any ancestor folder
|
||||
# has is_hidden=true, the new photo is
|
||||
# immediately marked hidden so it never
|
||||
# briefly appears in cross-cutting views
|
||||
# between scan and the next manual recompute.
|
||||
effective_hidden = await is_folder_effectively_hidden(folder)
|
||||
|
||||
# Create photo entry
|
||||
mtime_dt = datetime.fromtimestamp(stat.st_mtime)
|
||||
photo = Photo(
|
||||
filepath=filepath,
|
||||
filename=filename,
|
||||
folder_id=folder.id,
|
||||
user_id=owner_user_id,
|
||||
file_hash=file_hash,
|
||||
media_type=get_media_type(filepath),
|
||||
original_format=Path(filepath).suffix.upper()[1:],
|
||||
file_size=stat.st_size,
|
||||
taken_at=mtime_dt,
|
||||
taken_at_source='filesystem',
|
||||
# First-pass flag based on the filesystem mtime;
|
||||
# metadata.extract_metadata re-runs this once
|
||||
# EXIF has been parsed so a real DateTimeOriginal
|
||||
# can clear the warning.
|
||||
has_date_warning=has_date_warning(filepath, mtime_dt),
|
||||
is_duplicate=is_dup,
|
||||
is_hidden=effective_hidden,
|
||||
processing_status='pending'
|
||||
)
|
||||
|
||||
session.add(photo)
|
||||
await session.flush() # Assign defaults / FK ids
|
||||
|
||||
# Queue dispatch happens after the batch commit
|
||||
# below; otherwise the worker can race the writer
|
||||
# and see "Photo not found".
|
||||
pending_dispatch.append(photo.id)
|
||||
if photo.media_type == 'video':
|
||||
# Pre-transcode HEVC and other non-web-safe
|
||||
# videos at scan time so the user doesn't
|
||||
# pay the encode cost on first <video> click.
|
||||
pending_video_pretranscode.append(
|
||||
(photo.id, filepath)
|
||||
)
|
||||
|
||||
processed_files += 1
|
||||
progress_set(REDIS_KEY_PROCESSED, processed_files)
|
||||
|
||||
# Celery internal progress (used by celery tooling)
|
||||
if processed_files % 10 == 0:
|
||||
task.update_state(
|
||||
state='PROGRESS',
|
||||
meta={
|
||||
'current': processed_files,
|
||||
'total': total_files,
|
||||
'folder': root,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing file {filepath}: {e}")
|
||||
errors.append({'file': filepath, 'error': str(e)})
|
||||
progress_push_error(f"{filepath}: {e}")
|
||||
continue
|
||||
|
||||
# Commit batch, then queue worker tasks. Dispatch order
|
||||
# matters: commit first so workers can find the rows.
|
||||
await session.commit()
|
||||
|
||||
for photo_id in pending_dispatch:
|
||||
generate_thumbnails.delay(photo_id)
|
||||
extract_metadata.delay(photo_id)
|
||||
for vid_photo_id, vid_path in pending_video_pretranscode:
|
||||
pretranscode_video.delay(vid_photo_id, vid_path)
|
||||
|
||||
# Update folder scan timestamp
|
||||
folder.last_scanned = datetime.utcnow()
|
||||
folder.photo_count = processed_files
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Scan complete. Processed {processed_files}/{total_files} files. Errors: {len(errors)}")
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'processed': processed_files,
|
||||
'total': total_files,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scan failed: {e}")
|
||||
progress_push_error(f"scan failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
# Always mark inactive on the way out so a crashed scan doesn't
|
||||
# leave the UI thinking we're still scanning.
|
||||
progress_set(REDIS_KEY_ACTIVE, 'false')
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""Canonicalise a filesystem path so we don't get duplicate DB rows for
|
||||
the same physical directory due to trailing slashes, redundant separators,
|
||||
or `.` segments. Symlinks are NOT resolved (we want to keep mount paths
|
||||
intact for cross-machine portability)."""
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceRoot:
|
||||
"""Get or create a source root entry, matching by normalized path."""
|
||||
from sqlalchemy import select
|
||||
|
||||
norm = _normalize_path(path)
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.path == norm)
|
||||
)
|
||||
source_root = result.scalar_one_or_none()
|
||||
|
||||
if not source_root:
|
||||
source_root = SourceRoot(
|
||||
name=Path(norm).name,
|
||||
path=norm,
|
||||
)
|
||||
session.add(source_root)
|
||||
await session.flush()
|
||||
|
||||
return source_root
|
||||
|
||||
|
||||
async def get_or_create_folder(
|
||||
session: AsyncSession, path: str, source_root_id: str, user_id: str = None
|
||||
) -> Folder:
|
||||
"""Get or create a folder entry, matching by normalized path."""
|
||||
from sqlalchemy import select
|
||||
|
||||
norm = _normalize_path(path)
|
||||
result = await session.execute(
|
||||
select(Folder).where(Folder.path == norm)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
|
||||
if not folder:
|
||||
parent_path = _normalize_path(str(Path(norm).parent))
|
||||
|
||||
if parent_path != norm: # Not the filesystem root
|
||||
parent_result = await session.execute(
|
||||
select(Folder).where(Folder.path == parent_path)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if parent:
|
||||
parent_id = parent.id
|
||||
else:
|
||||
# Recursively create parent
|
||||
parent = await get_or_create_folder(session, parent_path, source_root_id, user_id)
|
||||
parent_id = parent.id
|
||||
else:
|
||||
parent_id = None
|
||||
|
||||
folder = Folder(
|
||||
name=Path(norm).name,
|
||||
path=norm,
|
||||
parent_id=parent_id,
|
||||
source_root_id=source_root_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
session.add(folder)
|
||||
await session.flush()
|
||||
|
||||
return folder
|
||||
|
||||
@shared_task(name='scan_all_source_roots')
|
||||
def scan_all_source_roots():
|
||||
"""Scan every active source root currently registered in the DB."""
|
||||
# Clear stale per-scan progress before queuing new work so the UI sees
|
||||
# a clean slate even if a previous run crashed mid-flight.
|
||||
r = _get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
r.delete(REDIS_KEY_ERRORS)
|
||||
r.set(REDIS_KEY_PROCESSED, 0)
|
||||
r.set(REDIS_KEY_TOTAL, 0)
|
||||
except Exception as e:
|
||||
logger.debug(f"scan_all_source_roots redis reset failed: {e}")
|
||||
|
||||
return asyncio.run(_scan_all_source_roots_async())
|
||||
|
||||
|
||||
async def _scan_all_source_roots_async():
|
||||
"""Read every active SourceRoot from the DB and queue a scan_folder task
|
||||
for each. Source roots whose path no longer exists on disk are skipped
|
||||
with a warning (the cleanup service surfaces those at startup too).
|
||||
|
||||
After dispatching the scans, queue a delayed `regroup_duplicates`
|
||||
pass so duplicate clusters are recomputed once the new photos have
|
||||
finished thumbnailing (and therefore picked up phashes). The
|
||||
countdown is a best-effort hint — on a big library the user can
|
||||
still hit Settings → Re-detect duplicates to force a fresh pass.
|
||||
"""
|
||||
from app.tasks.thumbs import incremental_regroup_duplicates_task
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
)
|
||||
source_roots = result.scalars().all()
|
||||
dispatched = 0
|
||||
for sr in source_roots:
|
||||
if os.path.exists(sr.path):
|
||||
scan_folder.delay(sr.path, sr.id)
|
||||
dispatched += 1
|
||||
else:
|
||||
logger.warning(f"Source root path does not exist: {sr.path}")
|
||||
|
||||
if dispatched > 0:
|
||||
# 60s gives the thumbs worker a window to compute phashes for
|
||||
# the new photos before regrouping. The task is idempotent, so
|
||||
# firing too early just means the next manual run picks up the
|
||||
# late arrivals — no corrupted state.
|
||||
try:
|
||||
# Use incremental mode: only compare newly added photos
|
||||
# against the full library via CLIP HNSW + pHash.
|
||||
# O(new × log N) instead of O(N²).
|
||||
scan_start = datetime.now(timezone.utc).isoformat()
|
||||
incremental_regroup_duplicates_task.apply_async(
|
||||
kwargs={'since_iso': scan_start},
|
||||
countdown=60,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not queue post-scan regroup: {e}")
|
||||
|
||||
# NOTE: we used to auto-queue `backfill_gps` here so photos
|
||||
# scanned before the GPS-extraction fix would eventually get
|
||||
# their coordinates populated. That fix shipped a long time
|
||||
# ago, so on every modern restart it just re-ran
|
||||
# extract_metadata for every photo that legitimately has no
|
||||
# GPS in EXIF (screenshots, indoor shots, scans) — tens of
|
||||
# thousands of pointless tasks that saturated worker-light
|
||||
# for ~30 min after each deploy. Trigger manually via
|
||||
# POST /api/v1/library/backfill-gps if you ever need it
|
||||
# again (e.g. another extractor-logic fix lands).
|
||||
|
||||
|
||||
@shared_task(name='watch_folders', bind=True)
|
||||
def watch_folders(self):
|
||||
"""Retired: file events now arrive via NC webhook_listeners.
|
||||
|
||||
Kept as a no-op task so any in-flight queue items (a leftover
|
||||
apply_async from a restart before this commit, or an admin-button
|
||||
trigger) don't crash workers. Will be removed entirely once the
|
||||
queue drains.
|
||||
"""
|
||||
logger.info(
|
||||
"watch_folders task is retired; file events come from NC "
|
||||
"webhook_listeners. No-op."
|
||||
)
|
||||
return {'status': 'retired'}
|
||||
|
||||
async def handle_file_deletion(filepath: str):
|
||||
"""Handle deletion of a file from the filesystem"""
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Photo).where(Photo.filepath == filepath)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if photo:
|
||||
# Mark as missing or delete from database
|
||||
photo.is_discarded = True
|
||||
photo.discarded_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
logger.info(f"Marked photo as discarded: {filepath}")
|
||||
|
||||
|
||||
async def handle_directory_deletion(dirpath: str) -> int:
|
||||
"""Mark every Photo under `dirpath` as discarded — used when Nextcloud
|
||||
fires a NodeDeletedEvent on a folder. NC emits ONE event for the
|
||||
folder itself (not one per child file), so without this we'd never
|
||||
see the children disappear except via the 30-min reconcile sweep.
|
||||
|
||||
Returns the number of photos affected. Matches by `filepath LIKE
|
||||
dirpath + '/%'` (the trailing slash is important — we don't want
|
||||
`/photos/foo` to also match `/photos/foobar.jpg`).
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
prefix = dirpath.rstrip("/") + "/"
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
update(Photo)
|
||||
.where(
|
||||
Photo.filepath.like(prefix + "%"),
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
.values(is_discarded=True, discarded_at=datetime.utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
n = result.rowcount or 0
|
||||
if n:
|
||||
logger.info(f"Marked {n} photos as discarded under {dirpath}")
|
||||
return n
|
||||
|
||||
|
||||
async def handle_directory_rename(old_dirpath: str, new_dirpath: str) -> dict:
|
||||
"""Reflect a Nextcloud-side folder rename in mule's DB.
|
||||
|
||||
NC emits a single NodeRenamedEvent on the directory — children
|
||||
don't get their own events. We mirror the same prefix-rewrite the
|
||||
PATCH /folders/{id} endpoint does inline, so heaps, tags, ratings,
|
||||
and other state keyed on Photo.id survive intact.
|
||||
|
||||
Same-source-root case (the common one): prefix-rewrite filepath /
|
||||
path on photos, folders, source_roots in one transaction.
|
||||
|
||||
Cross-source-root case (folder moved between two registered roots,
|
||||
e.g. Photos/x → Memories/x): discard the old subtree and rely on
|
||||
the scan_folder dispatched by a NodeWritten/NodeCreated event (or
|
||||
the 30-min reconcile sweep) to add fresh Photo rows under the new
|
||||
root. Mirrors the "different boundary, different identity" model
|
||||
mule has elsewhere.
|
||||
|
||||
Idempotent: re-running with the same args is a no-op because no
|
||||
row matches `LIKE old_prefix||'/%'` after the first pass. That
|
||||
makes the feedback loop (mule PATCH → WebDAV MOVE → NC webhook →
|
||||
handler) safe.
|
||||
"""
|
||||
from sqlalchemy import or_, text, update
|
||||
from app.models.folders import SourceRoot
|
||||
|
||||
old_prefix = old_dirpath.rstrip("/")
|
||||
new_prefix = new_dirpath.rstrip("/")
|
||||
if not old_prefix or not new_prefix or old_prefix == new_prefix:
|
||||
return {"status": "noop"}
|
||||
|
||||
async def _source_root_id_for(session, path: str) -> Optional[str]:
|
||||
"""Find the active SourceRoot whose path is a prefix of `path`."""
|
||||
roots = (await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
)).scalars().all()
|
||||
norm = os.path.normpath(path)
|
||||
for sr in roots:
|
||||
root = os.path.normpath(sr.path)
|
||||
if norm == root or norm.startswith(root + os.sep):
|
||||
return sr.id
|
||||
return None
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
old_root_id = await _source_root_id_for(session, old_prefix)
|
||||
new_root_id = await _source_root_id_for(session, new_prefix)
|
||||
|
||||
# Cross-root rename: discard old subtree; let webhook-dispatched
|
||||
# scan_folder add fresh rows under the new root.
|
||||
if old_root_id and new_root_id and old_root_id != new_root_id:
|
||||
result = await session.execute(
|
||||
update(Photo)
|
||||
.where(
|
||||
Photo.filepath.like(old_prefix + "/%"),
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
.values(is_discarded=True, discarded_at=datetime.utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
n = result.rowcount or 0
|
||||
logger.info(
|
||||
f"Cross-root rename {old_prefix} -> {new_prefix}: "
|
||||
f"discarded {n} photos in old root"
|
||||
)
|
||||
return {"status": "cross_root", "discarded": n}
|
||||
|
||||
# Same-root: iterate the matching rows in Python and rewrite
|
||||
# the prefix attribute-side. We tried a single UPDATE … SET …
|
||||
# SUBSTRING(... FROM LENGTH(:old)+1) raw-SQL approach but
|
||||
# asyncpg miscategorises the LENGTH() result and rejects it
|
||||
# as "$2: int (expected str)". The PATCH /folders/{id}
|
||||
# endpoint already loops in Python for the same reason — match
|
||||
# its pattern. Folder renames are rare and typically span ≤1k
|
||||
# photos, so per-row UPDATEs are fine.
|
||||
old_pat = old_prefix + "/%"
|
||||
photos = (await session.execute(
|
||||
select(Photo).where(Photo.filepath.like(old_pat))
|
||||
)).scalars().all()
|
||||
for p in photos:
|
||||
p.filepath = new_prefix + p.filepath[len(old_prefix):]
|
||||
|
||||
folders = (await session.execute(
|
||||
select(Folder).where(
|
||||
or_(
|
||||
Folder.path == old_prefix,
|
||||
Folder.path.like(old_pat),
|
||||
)
|
||||
)
|
||||
)).scalars().all()
|
||||
# Path is load-bearing (FKs join on it implicitly via filepath),
|
||||
# name is purely display. The renamed folder itself gets its
|
||||
# leaf basename refreshed too so the sidebar tree doesn't show
|
||||
# stale text. Descendant folders keep their existing name
|
||||
# because the rename was on the ancestor — only the path changes.
|
||||
new_basename = os.path.basename(new_prefix)
|
||||
for f in folders:
|
||||
if f.path == old_prefix:
|
||||
f.path = new_prefix
|
||||
f.name = new_basename
|
||||
else:
|
||||
f.path = new_prefix + f.path[len(old_prefix):]
|
||||
|
||||
source_roots = (await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.path == old_prefix)
|
||||
)).scalars().all()
|
||||
for sr in source_roots:
|
||||
sr.path = new_prefix
|
||||
await session.commit()
|
||||
return {
|
||||
"status": "renamed",
|
||||
"photos": len(photos),
|
||||
"folders": len(folders),
|
||||
"source_roots": len(source_roots),
|
||||
}
|
||||
|
||||
|
||||
@shared_task(name='backfill_gps')
|
||||
def backfill_gps():
|
||||
"""Re-run metadata extraction on every non-discarded photo that is
|
||||
missing latitude/longitude. Used both as a one-shot kick-off after the
|
||||
GPS columns are added on an existing install (see app/database.py) and
|
||||
as a manual trigger from POST /api/v1/library/backfill-gps. Each
|
||||
extract_metadata call is itself a Celery task, so this just enqueues —
|
||||
it does not block on extraction completing."""
|
||||
return asyncio.run(_backfill_gps_async())
|
||||
|
||||
|
||||
async def _backfill_gps_async():
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Newest-first so the most recent photos get their GPS + EXIF
|
||||
# written before the worker climbs back through the archive.
|
||||
result = await session.execute(
|
||||
select(Photo.id)
|
||||
.where(
|
||||
Photo.latitude.is_(None),
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
.order_by(
|
||||
Photo.taken_at.desc().nullslast(),
|
||||
Photo.added_at.desc().nullslast(),
|
||||
)
|
||||
)
|
||||
photo_ids = [row[0] for row in result.all()]
|
||||
|
||||
for pid in photo_ids:
|
||||
extract_metadata.delay(pid)
|
||||
|
||||
logger.info(f"backfill_gps: queued extract_metadata for {len(photo_ids)} photos")
|
||||
return {'queued': len(photo_ids)}
|
||||
|
||||
|
||||
@shared_task(name='discard_missing_photos_beat')
|
||||
def discard_missing_photos_beat():
|
||||
"""Periodic catch-up for filesystem deletions the watcher missed
|
||||
(e.g. while the worker was restarting). Walks every active source
|
||||
root that is currently mounted and present, and soft-discards any
|
||||
Photo whose file is gone. Hard-deletion stays manual via
|
||||
POST /api/v1/library/maintenance/prune-missing.
|
||||
|
||||
Wired to a 30-minute beat schedule in app/tasks/celery.py.
|
||||
"""
|
||||
from app.services.cleanup import discard_missing_photos
|
||||
return asyncio.run(discard_missing_photos())
|
||||
@@ -1,565 +0,0 @@
|
||||
"""
|
||||
Celery tasks for thumbnail generation
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
import json
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from PIL import Image
|
||||
import imageio
|
||||
from pillow_heif import register_heif_opener
|
||||
import ffmpeg
|
||||
|
||||
# Try to import optional libraries
|
||||
try:
|
||||
import pyvips
|
||||
PYVIPS_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYVIPS_AVAILABLE = False
|
||||
print("pyvips not available, using Pillow for image processing")
|
||||
|
||||
try:
|
||||
import rawpy
|
||||
RAWPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
RAWPY_AVAILABLE = False
|
||||
print("rawpy not available, using exiftool for RAW preview extraction")
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo
|
||||
from app.config import settings
|
||||
|
||||
# Register HEIF opener with Pillow
|
||||
register_heif_opener()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thumbnail sizes configuration
|
||||
THUMB_SIZES = {
|
||||
'small': settings.thumbnails.small,
|
||||
'medium': settings.thumbnails.medium,
|
||||
'large': settings.thumbnails.large
|
||||
}
|
||||
|
||||
# Sizes the worker writes to /data/thumbs. Empty set since Phase 4 —
|
||||
# the API serves all sizes via Nextcloud's /core/preview proxy.
|
||||
# generate_thumbnails still runs the decode-and-pHash side-effect
|
||||
# (perceptual dedup is mule-only and needs the original-resolution
|
||||
# pixels) but no longer touches the disk.
|
||||
WORKER_THUMB_SIZES: set[str] = set()
|
||||
|
||||
def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
|
||||
"""Get the path for a thumbnail file.
|
||||
|
||||
When user_id is provided, thumbnails are stored under a user-specific
|
||||
subdirectory to enforce isolation between users.
|
||||
"""
|
||||
if user_id:
|
||||
thumb_dir = f"/data/thumbs/{user_id}/{photo_id}"
|
||||
else:
|
||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
|
||||
|
||||
def process_standard_image(filepath: str) -> Image.Image:
|
||||
"""Process standard image formats (JPEG, PNG, etc.)"""
|
||||
return Image.open(filepath)
|
||||
|
||||
def process_raw_image(filepath: str) -> Image.Image:
|
||||
"""Process RAW image formats"""
|
||||
if RAWPY_AVAILABLE:
|
||||
try:
|
||||
with rawpy.imread(filepath) as raw:
|
||||
# Use half_size for faster processing
|
||||
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
|
||||
# Convert numpy array to PIL Image
|
||||
return Image.fromarray(rgb, 'RGB')
|
||||
except Exception as e:
|
||||
logger.warning(f"rawpy failed for {filepath}: {e}; trying embedded preview")
|
||||
preview = extract_raw_preview(filepath)
|
||||
if preview is not None:
|
||||
return preview
|
||||
# iPhone "Apple ProRAW" / Linear DNG has no embedded preview and
|
||||
# LibRaw rejects it as not-a-RAW. It IS a TIFF container with a
|
||||
# developed RGB image inside, so PIL opens it directly.
|
||||
try:
|
||||
logger.warning(f"embedded preview missing for {filepath}; trying PIL TIFF fallback")
|
||||
return Image.open(filepath)
|
||||
except Exception as e2:
|
||||
logger.error(f"PIL fallback also failed for {filepath}: {e2}")
|
||||
raise
|
||||
else:
|
||||
# Use exiftool to extract embedded preview
|
||||
return extract_raw_preview(filepath)
|
||||
|
||||
def extract_raw_preview(filepath: str) -> Optional[Image.Image]:
|
||||
"""Extract embedded JPEG preview from RAW file"""
|
||||
try:
|
||||
# Use exiftool to extract preview
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||
cmd = ['exiftool', '-b', '-PreviewImage', filepath]
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
|
||||
if result.returncode == 0 and result.stdout:
|
||||
tmp.write(result.stdout)
|
||||
tmp.flush()
|
||||
return Image.open(tmp.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def process_heic_image(filepath: str) -> Image.Image:
|
||||
"""Process HEIC/HEIF image formats.
|
||||
|
||||
Tries pillow-heif first (fast, native). Falls back to ffmpeg for
|
||||
files that libheif rejects — e.g. iPhone photos with too many
|
||||
auxiliary image references (depth maps, gain maps).
|
||||
"""
|
||||
try:
|
||||
img = Image.open(filepath)
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
return img
|
||||
except Exception as e:
|
||||
logger.warning(f"pillow-heif failed for {filepath}: {e} — trying vips")
|
||||
|
||||
# vips fallback: handles tiled Apple HEIC files (bursts, HDR gain
|
||||
# maps, depth maps) that pillow-heif/libheif rejects due to too many
|
||||
# auxiliary image references.
|
||||
import subprocess, tempfile
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
result = subprocess.run(
|
||||
['vips', 'heifload', filepath, tmp_path],
|
||||
capture_output=True, timeout=60, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
img = Image.open(tmp_path).convert('RGB')
|
||||
os.unlink(tmp_path)
|
||||
return img
|
||||
logger.error(f"vips HEIC decode failed for {filepath}: {result.stderr.decode()[-200:]}")
|
||||
os.unlink(tmp_path)
|
||||
except Exception as e2:
|
||||
logger.error(f"vips fallback failed for {filepath}: {e2}")
|
||||
raise RuntimeError(f"Cannot decode HEIC: {filepath}")
|
||||
|
||||
def process_video_thumbnail(filepath: str) -> Image.Image:
|
||||
"""Extract a still frame from a video file as a PIL Image."""
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
# Find a usable seek timestamp. Some camera MOVs only expose
|
||||
# duration at the format level, and stream 0 isn't always the
|
||||
# video stream — search explicitly and fall back to the format
|
||||
# duration, then to t=0 if neither is available.
|
||||
probe = ffmpeg.probe(filepath)
|
||||
duration: Optional[float] = None
|
||||
for stream_info in probe.get('streams', []):
|
||||
if stream_info.get('codec_type') != 'video':
|
||||
continue
|
||||
raw_duration = stream_info.get('duration')
|
||||
if raw_duration is not None:
|
||||
try:
|
||||
duration = float(raw_duration)
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if duration is None:
|
||||
raw_duration = probe.get('format', {}).get('duration')
|
||||
if raw_duration is not None:
|
||||
try:
|
||||
duration = float(raw_duration)
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
|
||||
# Seek to 10% in for a representative frame; clamp very short
|
||||
# clips to t=0 so we don't seek past the end.
|
||||
timestamp = max(0.0, (duration or 0.0) * 0.1)
|
||||
|
||||
# NamedTemporaryFile creates the file on disk, so we MUST tell
|
||||
# ffmpeg to overwrite it (otherwise it prompts on stdin and the
|
||||
# call hangs/fails — which is why videos were getting the gray
|
||||
# placeholder). We close the handle immediately and clean up
|
||||
# in `finally` ourselves.
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
stream = ffmpeg.input(filepath, ss=timestamp)
|
||||
stream = ffmpeg.output(
|
||||
stream,
|
||||
tmp_path,
|
||||
vframes=1,
|
||||
format='image2',
|
||||
vcodec='mjpeg',
|
||||
)
|
||||
ffmpeg.run(
|
||||
stream,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
overwrite_output=True,
|
||||
)
|
||||
|
||||
# Load the frame fully into memory so we can delete the temp
|
||||
# file immediately. Pillow's `Image.open` is lazy, which would
|
||||
# otherwise leave the file dangling.
|
||||
with open(tmp_path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
if not data:
|
||||
raise RuntimeError("ffmpeg produced an empty frame")
|
||||
return Image.open(BytesIO(data)).copy()
|
||||
except ffmpeg.Error as e:
|
||||
stderr = (e.stderr or b'').decode('utf-8', errors='replace')
|
||||
logger.error(
|
||||
f"ffmpeg failed extracting video thumbnail from {filepath}: {stderr}"
|
||||
)
|
||||
return create_placeholder_thumbnail('video')
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
|
||||
return create_placeholder_thumbnail('video')
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
|
||||
"""Create a placeholder thumbnail for failed processing"""
|
||||
# Create a simple gray placeholder
|
||||
img = Image.new('RGB', (640, 480), color=(128, 128, 128))
|
||||
return img
|
||||
|
||||
def auto_rotate_image(image: Image.Image) -> Image.Image:
|
||||
"""Auto-rotate image based on EXIF orientation"""
|
||||
try:
|
||||
# Get EXIF data
|
||||
exif = image._getexif()
|
||||
if exif:
|
||||
orientation = exif.get(274) # Orientation tag
|
||||
|
||||
rotation_map = {
|
||||
3: 180,
|
||||
6: 270, # Note: PIL uses different rotation values than vips
|
||||
8: 90
|
||||
}
|
||||
|
||||
if orientation in rotation_map:
|
||||
image = image.rotate(rotation_map[orientation], expand=True)
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
pass # No orientation data available
|
||||
|
||||
return image
|
||||
|
||||
def generate_thumbnail(image: Image.Image, size: int, output_path: str):
|
||||
"""Generate a thumbnail of the specified size.
|
||||
|
||||
Works on a copy so the caller's image is never mutated — this is
|
||||
critical because the thumbnail loop iterates multiple sizes and
|
||||
in-place shrinking would degrade later (larger) sizes.
|
||||
"""
|
||||
img = image.copy()
|
||||
img.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
img.save(
|
||||
output_path,
|
||||
'WEBP',
|
||||
quality=settings.thumbnails.quality,
|
||||
method=4 # Balance between speed and compression
|
||||
)
|
||||
img.close()
|
||||
|
||||
@shared_task(bind=True, name='generate_thumbnails')
|
||||
def generate_thumbnails(self, photo_id: str):
|
||||
"""Generate thumbnails for a photo"""
|
||||
return asyncio.run(_generate_thumbnails_async(photo_id, self))
|
||||
|
||||
async def _generate_thumbnails_async(photo_id: str, task):
|
||||
"""Async implementation of thumbnail generation"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Declared up front so the except block below can safely check it
|
||||
# even if the initial SELECT raises (e.g. asyncpg transport error).
|
||||
photo: Optional[Photo] = None
|
||||
try:
|
||||
# Get photo from database
|
||||
result = await session.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
logger.error(f"Photo not found: {photo_id}")
|
||||
return {'status': 'error', 'message': 'Photo not found'}
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(photo.filepath):
|
||||
logger.error(f"File not found: {photo.filepath}")
|
||||
photo.processing_status = 'failed'
|
||||
photo.processing_error = 'File not found'
|
||||
await session.commit()
|
||||
return {'status': 'error', 'message': 'File not found'}
|
||||
|
||||
# Update processing status
|
||||
photo.processing_status = 'processing'
|
||||
await session.commit()
|
||||
|
||||
# Load and process the image based on type
|
||||
image = None
|
||||
|
||||
if photo.media_type == 'photo':
|
||||
image = process_standard_image(photo.filepath)
|
||||
elif photo.media_type == 'raw':
|
||||
image = process_raw_image(photo.filepath)
|
||||
elif photo.media_type == 'heic':
|
||||
image = process_heic_image(photo.filepath)
|
||||
elif photo.media_type == 'video':
|
||||
image = process_video_thumbnail(photo.filepath)
|
||||
else:
|
||||
logger.error(f"Unsupported media type: {photo.media_type}")
|
||||
image = create_placeholder_thumbnail(photo.media_type)
|
||||
|
||||
# Fallback: some files wear a RAW/HEIC extension but are actually
|
||||
# plain JPEGs — e.g. iPhones that write ProRAW-style .DNG for
|
||||
# images where no RAW sensor data was captured, or re-exports
|
||||
# that kept the original suffix. Pillow can open them directly,
|
||||
# so before giving up, try reading the file as a standard image.
|
||||
if not image and photo.media_type in ('raw', 'heic'):
|
||||
try:
|
||||
image = process_standard_image(photo.filepath)
|
||||
if image is not None:
|
||||
logger.info(
|
||||
f"{photo.filepath}: {photo.media_type} decode failed "
|
||||
f"but file opens as a standard image — using fallback"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Standard-image fallback failed for {photo.filepath}: {e}"
|
||||
)
|
||||
|
||||
if not image:
|
||||
raise Exception("Failed to process image")
|
||||
|
||||
# Auto-rotate based on EXIF
|
||||
image = auto_rotate_image(image)
|
||||
|
||||
# Store original dimensions
|
||||
photo.width = image.width
|
||||
photo.height = image.height
|
||||
|
||||
# Perceptual hash from the original-resolution decoded frame.
|
||||
# pHash is robust to resize/recompression but the thumbnail
|
||||
# loop below mutates `image` in place, so this MUST run before
|
||||
# the loop sees it. Failures are non-fatal — phash is a
|
||||
# nice-to-have, not a blocker for thumbnail generation.
|
||||
try:
|
||||
import imagehash
|
||||
photo.phash = str(imagehash.phash(image)) # 16-char hex
|
||||
except Exception as e:
|
||||
logger.warning(f"phash failed for {photo_id}: {e}")
|
||||
photo.phash = None
|
||||
|
||||
# Generate only the sizes the worker still owns on disk
|
||||
# (see WORKER_THUMB_SIZES above). The API serves the rest
|
||||
# via Nextcloud's preview endpoint.
|
||||
for size_name, size_value in THUMB_SIZES.items():
|
||||
if size_name not in WORKER_THUMB_SIZES:
|
||||
continue
|
||||
thumb_path = get_thumb_path(photo_id, size_name, photo.user_id)
|
||||
generate_thumbnail(image, size_value, thumb_path)
|
||||
|
||||
# Update database with thumbnail path
|
||||
setattr(photo, f'thumb_{size_name}', thumb_path)
|
||||
|
||||
# Update progress
|
||||
task.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'current_size': size_name, 'photo_id': photo_id}
|
||||
)
|
||||
|
||||
# Update processing status
|
||||
photo.processing_status = 'completed'
|
||||
photo.processing_error = None
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Thumbnails generated for photo {photo_id}")
|
||||
|
||||
return {'status': 'success', 'photo_id': photo_id}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnails for {photo_id}: {e}")
|
||||
|
||||
# Update error status. If the session is in a bad state (e.g.
|
||||
# the original failure was a transport error) rollback first so
|
||||
# the status write has a clean transaction to commit into.
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if photo is not None:
|
||||
try:
|
||||
photo.processing_status = 'failed'
|
||||
photo.processing_error = str(e)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Could not mark photo {photo_id} as failed"
|
||||
)
|
||||
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@shared_task(name='regenerate_all_thumbnails')
|
||||
def regenerate_all_thumbnails():
|
||||
"""Regenerate thumbnails for all photos"""
|
||||
return asyncio.run(_regenerate_all_thumbnails_async())
|
||||
|
||||
async def _regenerate_all_thumbnails_async():
|
||||
"""Async implementation of regenerating all thumbnails.
|
||||
|
||||
Queue order matters on first-boot and recovery runs: we dispatch
|
||||
newest-first (by EXIF taken_at, fallback added_at) so the user's
|
||||
most recent photos become fully-indexed before the 2012 archive even
|
||||
starts. Picking up the library in pipeline order means the grid,
|
||||
timeline and All Photos view populate top-down instead of the worker
|
||||
chewing through random insertion-order rows while the UI still
|
||||
shows grey placeholders.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Get all photos that need thumbnails, newest first.
|
||||
result = await session.execute(
|
||||
select(Photo)
|
||||
.where(Photo.processing_status.in_(['pending', 'failed']))
|
||||
.order_by(
|
||||
Photo.taken_at.desc().nullslast(),
|
||||
Photo.added_at.desc().nullslast(),
|
||||
)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
logger.info(f"Regenerating thumbnails for {len(photos)} photos")
|
||||
|
||||
for photo in photos:
|
||||
generate_thumbnails.delay(photo.id)
|
||||
|
||||
return {'status': 'queued', 'count': len(photos)}
|
||||
|
||||
|
||||
# ── Perceptual hash backfill ────────────────────────────────────────────
|
||||
#
|
||||
# When phash was added post-launch, every existing photo has phash=NULL.
|
||||
# This task fills them in by reading the existing thumb_large (the cheap
|
||||
# option — pHash is robust to scale, and the thumb is already on local
|
||||
# disk so we avoid re-decoding the original RAW/HEIC). Falls back to the
|
||||
# original filepath if the thumb isn't available for some reason. Runs
|
||||
# in batches to keep memory bounded and to give the user incremental
|
||||
# progress visible in the worker logs.
|
||||
|
||||
@shared_task(name='backfill_phashes')
|
||||
def backfill_phashes():
|
||||
"""Compute and persist phash for every photo currently missing one."""
|
||||
return asyncio.run(_backfill_phashes_async())
|
||||
|
||||
|
||||
async def _backfill_phashes_async():
|
||||
import imagehash
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
BATCH = 100
|
||||
total_done = 0
|
||||
total_failed = 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
while True:
|
||||
# Newest-first so the recent end of the library gets phashes
|
||||
# (and therefore duplicate detection) ahead of the archive.
|
||||
result = await session.execute(
|
||||
select(Photo)
|
||||
.where(Photo.phash.is_(None))
|
||||
.where(Photo.processing_status == 'completed')
|
||||
.order_by(
|
||||
Photo.taken_at.desc().nullslast(),
|
||||
Photo.added_at.desc().nullslast(),
|
||||
)
|
||||
.limit(BATCH)
|
||||
)
|
||||
batch = result.scalars().all()
|
||||
if not batch:
|
||||
break
|
||||
|
||||
for photo in batch:
|
||||
source = photo.thumb_large or photo.filepath
|
||||
try:
|
||||
if not source or not os.path.exists(source):
|
||||
photo.phash = None
|
||||
total_failed += 1
|
||||
continue
|
||||
with _PILImage.open(source) as im:
|
||||
photo.phash = str(imagehash.phash(im))
|
||||
total_done += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"phash backfill failed for {photo.id}: {e}")
|
||||
total_failed += 1
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Backfilled phashes: {total_done} done, {total_failed} failed"
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'computed': total_done,
|
||||
'failed': total_failed,
|
||||
}
|
||||
|
||||
|
||||
@shared_task(
|
||||
name='regroup_duplicates',
|
||||
# Full regroup scales with O(N²) on phash plus one pgvector query per
|
||||
# embedded photo. On a 16k-photo library that's comfortably past the
|
||||
# default 5-minute soft limit — bump to 2h / 2h30m. (Passing None here
|
||||
# does NOT disable limits; Celery falls back to the worker default
|
||||
# of 300s/600s. An explicit number overrides.)
|
||||
soft_time_limit=7200,
|
||||
time_limit=9000,
|
||||
)
|
||||
def regroup_duplicates_task():
|
||||
"""Full recompute of duplicate groups (pHash + CLIP similarity).
|
||||
|
||||
Used by the Settings → Re-detect duplicates button."""
|
||||
from app.services.duplicates import regroup_duplicates
|
||||
return asyncio.run(regroup_duplicates())
|
||||
|
||||
|
||||
@shared_task(
|
||||
name='incremental_regroup_duplicates',
|
||||
# O(new × N); still cheaper than a full regroup but can easily exceed
|
||||
# the 5-minute default after a big batch import. Same caveat as
|
||||
# regroup_duplicates above — None would just re-inherit the worker
|
||||
# default, so we pass explicit values.
|
||||
soft_time_limit=3600,
|
||||
time_limit=4200,
|
||||
)
|
||||
def incremental_regroup_duplicates_task(since_iso: str | None = None):
|
||||
"""Incremental duplicate detection for newly added photos.
|
||||
|
||||
Compares only photos added after `since_iso` against the full library
|
||||
using CLIP vector similarity (O(new × log N) via HNSW) plus pHash.
|
||||
Default post-scan path — much faster than a full regroup."""
|
||||
from app.services.duplicates import incremental_regroup
|
||||
from datetime import datetime, timezone
|
||||
since = None
|
||||
if since_iso:
|
||||
since = datetime.fromisoformat(since_iso)
|
||||
return asyncio.run(incremental_regroup(since=since))
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Background pre-transcode for video photos so /playback is a cache
|
||||
hit on first user click. Dispatched from scan_folder when a new video
|
||||
row is created, and from the backfill admin endpoint for the existing
|
||||
library."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from app.services.video import (
|
||||
PLAYBACK_OK_EXTS,
|
||||
PLAYBACK_OK_VCODECS,
|
||||
cache_path_for,
|
||||
ffprobe_video_codec,
|
||||
transcode_to_h264_mp4,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(
|
||||
name='pretranscode_video',
|
||||
# Override the 10-minute global task_time_limit. Long videos (30+ min
|
||||
# raw clips, the occasional .3gp from the 2016 archive) can legitimately
|
||||
# take half an hour to transcode on this CPU-only box.
|
||||
time_limit=1800,
|
||||
soft_time_limit=1740,
|
||||
)
|
||||
def pretranscode_video(photo_id: str, src_path: str):
|
||||
"""Idempotent: skip if cache exists and is newer than source, skip
|
||||
if source is already a passthrough-safe codec/container.
|
||||
|
||||
Reports the outcome as a status string so the admin backfill can
|
||||
summarise. The /playback endpoint also falls back to a sync
|
||||
transcode if the cache miss races a queued task."""
|
||||
if not os.path.exists(src_path):
|
||||
logger.debug("pretranscode skip — source missing: %s", src_path)
|
||||
return {'status': 'missing', 'photo_id': photo_id}
|
||||
|
||||
cache_path = cache_path_for(photo_id)
|
||||
if cache_path.exists():
|
||||
try:
|
||||
if os.path.getmtime(src_path) <= os.path.getmtime(cache_path):
|
||||
return {'status': 'cached', 'photo_id': photo_id}
|
||||
cache_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ext = os.path.splitext(src_path)[1].lower()
|
||||
if ext in PLAYBACK_OK_EXTS:
|
||||
codec = ffprobe_video_codec(src_path)
|
||||
if codec in PLAYBACK_OK_VCODECS:
|
||||
return {'status': 'passthrough', 'photo_id': photo_id, 'codec': codec}
|
||||
|
||||
ok = transcode_to_h264_mp4(src_path, str(cache_path))
|
||||
return {
|
||||
'status': 'transcoded' if ok else 'failed',
|
||||
'photo_id': photo_id,
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Post-init_db bootstrap: run or stamp Alembic migrations.
|
||||
|
||||
On a FRESH Postgres install, init_db's create_all has already built the
|
||||
full schema from the current models. Running `alembic upgrade head` would
|
||||
fail because the older migrations try ADD COLUMN on columns that already
|
||||
exist. So we detect the fresh-install case (alembic_version table is
|
||||
missing or empty) and `stamp head` instead.
|
||||
|
||||
On an EXISTING install, the alembic_version table has a revision and
|
||||
`upgrade head` applies only the new deltas.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from sqlalchemy import create_engine, text, inspect
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def run():
|
||||
# Use a sync engine for this one-shot script.
|
||||
sync_url = settings.database_url.replace("+asyncpg", "").replace("+aiosqlite", "")
|
||||
engine = create_engine(sync_url)
|
||||
|
||||
with engine.connect() as conn:
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if "alembic_version" not in tables:
|
||||
# Fresh install — create_all built everything. Stamp head.
|
||||
print("Fresh install detected — stamping alembic head")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "stamp", "head"],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
row = conn.execute(text("SELECT version_num FROM alembic_version")).first()
|
||||
if row is None:
|
||||
print("Empty alembic_version — stamping head")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "stamp", "head"],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
print(f"Existing install at revision {row[0]} — running alembic upgrade head")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,66 +0,0 @@
|
||||
# Core dependencies
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
aiosqlite==0.19.0 # SQLite escape hatch (docker-compose.sqlite.yml override)
|
||||
asyncpg==0.29.0 # async Postgres driver (default)
|
||||
psycopg2-binary==2.9.9 # sync Postgres driver, used by Alembic CLI
|
||||
alembic==1.13.1
|
||||
|
||||
# Redis and Celery
|
||||
redis==5.0.1
|
||||
celery==5.3.6
|
||||
flower==2.0.1
|
||||
|
||||
# Image processing
|
||||
# pyvips==2.2.1 # Optional - having compatibility issues, using Pillow as fallback
|
||||
rawpy==0.26.1 # RAW decoder (CR2/NEF/ARW/DNG/…). cp312 wheels
|
||||
# ship with libraw bundled; the older 0.19 pin
|
||||
# had numpy 2.x incompatibilities — 0.26 is fine
|
||||
# with our numpy 1.26. iPhone ProRAW-style DNGs
|
||||
# that aren't real RAW still fail here; thumbs.py
|
||||
# falls back to opening them as JPEG in that case.
|
||||
pillow==10.2.0
|
||||
pillow-heif==0.15.0
|
||||
imagehash==4.3.1 # perceptual hash for duplicate detection
|
||||
imageio==2.33.1
|
||||
imageio-ffmpeg==0.4.9
|
||||
|
||||
# Video processing
|
||||
ffmpeg-python==0.2.0
|
||||
|
||||
# Metadata extraction
|
||||
pyexiftool==0.5.6
|
||||
|
||||
# File watching
|
||||
watchfiles==0.21.0
|
||||
|
||||
# Transitive dep of rawpy 0.26.1, which requires numpy<2 (see comment above).
|
||||
numpy>=1.26.0,<2.0
|
||||
|
||||
# Utilities
|
||||
pyyaml==6.0.1
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
httpx[http2]==0.26.0
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Security and authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
# OIDC single sign-on (Authentik, etc.). Authlib drives the Auth Code +
|
||||
# PKCE flow; itsdangerous signs the short-lived Starlette session cookie
|
||||
# that holds the PKCE state during the IdP round-trip.
|
||||
authlib==1.3.1
|
||||
itsdangerous==2.1.2
|
||||
|
||||
# Development
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
black==23.12.1
|
||||
ruff==0.1.11
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Backfill Photo.nextcloud_fileid for photos under Nextcloud-rooted paths.
|
||||
|
||||
The Phase-1 thumbnail proxy reads `Photo.nextcloud_fileid` to know which
|
||||
file to ask Nextcloud's /core/preview endpoint about. New photos pick it
|
||||
up at scan time; this script catches up the existing library.
|
||||
|
||||
Run inside the backend container, e.g.:
|
||||
|
||||
pct exec 120 -- docker exec mulita-backend python -m scripts.backfill_nextcloud_fileid
|
||||
|
||||
Idempotent: skips rows that already have nextcloud_fileid set, and any
|
||||
row whose path isn't under the Nextcloud bind mount. One PROPFIND per
|
||||
photo. At ~50ms each that's ~18 minutes for a 22k-row library — run
|
||||
during off-hours.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo
|
||||
from app.models.user import User
|
||||
from app.services.nextcloud_dav import fetch_fileid, is_nextcloud_path
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("backfill_nextcloud_fileid")
|
||||
|
||||
BATCH = 500
|
||||
|
||||
|
||||
async def _user_cache(session: AsyncSession) -> dict[str, User]:
|
||||
"""One SELECT per script run instead of per photo."""
|
||||
result = await session.execute(select(User))
|
||||
return {u.id: u for u in result.scalars().all()}
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
users = await _user_cache(session)
|
||||
total = await session.scalar(
|
||||
select(func.count(Photo.id)).where(Photo.nextcloud_fileid.is_(None))
|
||||
)
|
||||
logger.info("photos with NULL nextcloud_fileid: %s", total)
|
||||
|
||||
done = 0
|
||||
skipped_no_user = 0
|
||||
skipped_not_nc = 0
|
||||
skipped_no_creds = 0
|
||||
filled = 0
|
||||
not_found = 0
|
||||
|
||||
# Keep selecting the next batch of NULL-fileid rows until the
|
||||
# set is empty. NO offset() — each batch's writes shrink the
|
||||
# `WHERE nextcloud_fileid IS NULL` set, so an offset would skip
|
||||
# over the rows that were just filled in by the previous batch.
|
||||
# Rows we couldn't resolve (skipped or not_found) stay in the
|
||||
# set; we track them in a "stuck ids" set so the loop terminates
|
||||
# instead of spinning on them forever.
|
||||
stuck: set[str] = set()
|
||||
while True:
|
||||
stmt = (
|
||||
select(Photo)
|
||||
.where(Photo.nextcloud_fileid.is_(None))
|
||||
.order_by(Photo.id)
|
||||
.limit(BATCH)
|
||||
)
|
||||
if stuck:
|
||||
stmt = stmt.where(Photo.id.notin_(stuck))
|
||||
result = await session.execute(stmt)
|
||||
rows = list(result.scalars().all())
|
||||
if not rows:
|
||||
break
|
||||
|
||||
batch_started_filled = filled
|
||||
for photo in rows:
|
||||
done += 1
|
||||
if not photo.user_id:
|
||||
skipped_no_user += 1
|
||||
stuck.add(photo.id)
|
||||
continue
|
||||
owner = users.get(photo.user_id)
|
||||
if owner is None:
|
||||
skipped_no_user += 1
|
||||
stuck.add(photo.id)
|
||||
continue
|
||||
if not photo.filepath or not is_nextcloud_path(photo.filepath):
|
||||
skipped_not_nc += 1
|
||||
stuck.add(photo.id)
|
||||
continue
|
||||
if not owner.nextcloud_app_password_enc:
|
||||
skipped_no_creds += 1
|
||||
stuck.add(photo.id)
|
||||
continue
|
||||
fid: Optional[int] = None
|
||||
try:
|
||||
fid = fetch_fileid(owner, photo.filepath)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"PROPFIND failed for photo %s (%s): %s",
|
||||
photo.id, photo.filepath, e,
|
||||
)
|
||||
if fid is None:
|
||||
not_found += 1
|
||||
stuck.add(photo.id)
|
||||
continue
|
||||
photo.nextcloud_fileid = fid
|
||||
filled += 1
|
||||
|
||||
await session.commit()
|
||||
# Safety: if a whole batch produced no new fills, every row
|
||||
# in it is already in `stuck` — break to avoid an infinite
|
||||
# loop on the same set.
|
||||
if filled == batch_started_filled and len(rows) < BATCH:
|
||||
break
|
||||
logger.info(
|
||||
"progress: scanned=%s filled=%s not_found=%s "
|
||||
"skipped(no_user=%s not_nc=%s no_creds=%s) of total=%s",
|
||||
done, filled, not_found,
|
||||
skipped_no_user, skipped_not_nc, skipped_no_creds,
|
||||
total,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"done: scanned=%s filled=%s not_found=%s "
|
||||
"skipped(no_user=%s not_nc=%s no_creds=%s)",
|
||||
done, filled, not_found,
|
||||
skipped_no_user, skipped_not_nc, skipped_no_creds,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Bring the mule-image DB into 100% sync with Nextcloud + filesystem.
|
||||
|
||||
Multi-phase one-shot operation invoked via:
|
||||
|
||||
docker exec mulita-backend python scripts/full_refresh.py [--dry-run]
|
||||
|
||||
Phases:
|
||||
1. Data integrity (sync, ~1s): cleanup_data_integrity dedupes
|
||||
SourceRoots / Folders by normalized path and recomputes folder
|
||||
photo_count.
|
||||
2. Forward scan (async, minutes): walk every active SourceRoot on
|
||||
disk, create/update Photo rows for new files, resurrect any
|
||||
accidentally-discarded photos whose mtime advanced.
|
||||
3. Hard prune (sync, seconds): delete Photo + Folder rows for paths
|
||||
that no longer exist on disk under a *mounted* root. Skips
|
||||
unmounted roots — matches prune_missing_photos's existing
|
||||
refuse-when-empty behavior.
|
||||
4. Orphan thumbnail dirs (sync, seconds): remove
|
||||
/data/thumbs/{user_id}/{photo_id}/ for any photo_id that's no
|
||||
longer in the photos table.
|
||||
|
||||
Pass --dry-run to compute counts for phases 3+4 without making changes.
|
||||
Phases 1 and 2 always run for real — they're idempotent and additive.
|
||||
|
||||
Print a structured summary at the end. Exit non-zero on any phase
|
||||
error; partial completion still surfaces the counts gathered so far.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from app.services.cleanup import (
|
||||
cleanup_data_integrity,
|
||||
prune_missing_photos,
|
||||
prune_orphan_thumbnails,
|
||||
)
|
||||
from app.tasks.scan import _scan_folder_async
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.folders import SourceRoot
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("full_refresh")
|
||||
|
||||
|
||||
async def _scan_all_inline() -> int:
|
||||
"""Scan every active SourceRoot inline (not via celery). Returns the
|
||||
number of roots actually walked."""
|
||||
import os
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active.is_(True))
|
||||
)
|
||||
roots = result.scalars().all()
|
||||
|
||||
walked = 0
|
||||
for sr in roots:
|
||||
if not os.path.exists(sr.path):
|
||||
logger.warning("source root path missing, skipping: %s", sr.path)
|
||||
continue
|
||||
logger.info("scanning %s …", sr.path)
|
||||
await _scan_folder_async(sr.path, sr.id, task=None)
|
||||
walked += 1
|
||||
return walked
|
||||
|
||||
|
||||
async def main(dry_run: bool) -> dict:
|
||||
summary: dict = {"dry_run": dry_run}
|
||||
|
||||
logger.info("phase 1: cleanup_data_integrity")
|
||||
summary["phase1_cleanup"] = await cleanup_data_integrity()
|
||||
|
||||
logger.info("phase 2: scan_all_source_roots (inline)")
|
||||
summary["phase2_scan_roots_walked"] = await _scan_all_inline()
|
||||
|
||||
logger.info("phase 3: prune_missing_photos (dry_run=%s)", dry_run)
|
||||
summary["phase3_prune"] = await prune_missing_photos(dry_run=dry_run)
|
||||
|
||||
logger.info("phase 4: prune_orphan_thumbnails (dry_run=%s)", dry_run)
|
||||
summary["phase4_orphan_thumbs"] = await prune_orphan_thumbnails(
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def cli() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Phases 3+4 report counts without making changes",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = asyncio.run(main(dry_run=args.dry_run))
|
||||
except Exception:
|
||||
logger.exception("full_refresh failed")
|
||||
return 1
|
||||
|
||||
import json
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(cli())
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Register (or re-register) the four file-event webhooks against the
|
||||
Nextcloud webhook_listeners app, pointing them at mule's internal
|
||||
receiver.
|
||||
|
||||
Idempotent: deletes any existing webhooks whose URI matches the target
|
||||
mule URL before posting fresh ones. Run after a config change (target
|
||||
URL, secret) or after the NC stack is rebuilt fresh.
|
||||
|
||||
docker exec mulita-backend python -m scripts.register_nc_webhooks
|
||||
|
||||
Reads:
|
||||
- NEXTCLOUD_BASE_URL — already set for the DAV client
|
||||
- NEXTCLOUD_WEBHOOK_TARGET — http URL of mule's webhook endpoint
|
||||
(default: http://192.168.8.136:8001/api/v1/internal/nc-webhook)
|
||||
- NEXTCLOUD_WEBHOOK_SECRET — shared bearer secret; must match the
|
||||
backend env var of the same name
|
||||
|
||||
Registers as the first mule user with `is_admin=true` (or, failing
|
||||
that, the first user with NC credentials configured). The OCS endpoint
|
||||
itself only requires basic auth as that NC user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.secrets import decrypt
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("register_nc_webhooks")
|
||||
|
||||
DEFAULT_TARGET = "http://192.168.8.136:8001/api/v1/internal/nc-webhook"
|
||||
|
||||
EVENTS = [
|
||||
"OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"OCP\\Files\\Events\\Node\\NodeWrittenEvent",
|
||||
"OCP\\Files\\Events\\Node\\NodeDeletedEvent",
|
||||
"OCP\\Files\\Events\\Node\\NodeRenamedEvent",
|
||||
]
|
||||
|
||||
|
||||
async def _pick_admin() -> User | None:
|
||||
"""Pick a mule user we can use to authenticate against NC's OCS
|
||||
API. Prefer admin role, fall back to any user with NC creds set."""
|
||||
async with AsyncSessionLocal() as s:
|
||||
# First try admins.
|
||||
r = await s.execute(
|
||||
select(User).where(
|
||||
User.role == "admin",
|
||||
User.nextcloud_app_password_enc.is_not(None),
|
||||
)
|
||||
)
|
||||
u = r.scalars().first()
|
||||
if u:
|
||||
return u
|
||||
# Fall back to any user with creds.
|
||||
r = await s.execute(
|
||||
select(User).where(User.nextcloud_app_password_enc.is_not(None))
|
||||
)
|
||||
return r.scalars().first()
|
||||
|
||||
|
||||
def _ocs(base: str, path: str) -> str:
|
||||
return f"{base.rstrip('/')}/ocs/v2.php/apps/webhook_listeners/api/v1{path}"
|
||||
|
||||
|
||||
def _ocs_headers() -> dict[str, str]:
|
||||
# OCS-APIRequest header is mandatory for OCS endpoints.
|
||||
return {
|
||||
"OCS-APIRequest": "true",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _list_existing(c: httpx.Client, base: str) -> list[dict[str, Any]]:
|
||||
r = c.get(_ocs(base, "/webhooks"), headers=_ocs_headers())
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
return body.get("ocs", {}).get("data", []) or []
|
||||
|
||||
|
||||
def _delete(c: httpx.Client, base: str, webhook_id: str) -> None:
|
||||
r = c.delete(_ocs(base, f"/webhooks/{webhook_id}"), headers=_ocs_headers())
|
||||
if r.status_code not in (200, 204):
|
||||
logger.warning(
|
||||
"delete webhook %s returned %s: %s",
|
||||
webhook_id, r.status_code, r.text[:200],
|
||||
)
|
||||
|
||||
|
||||
def _register(
|
||||
c: httpx.Client,
|
||||
base: str,
|
||||
target: str,
|
||||
secret: str,
|
||||
event_class: str,
|
||||
) -> dict[str, Any]:
|
||||
body = {
|
||||
"uri": target,
|
||||
"httpMethod": "POST",
|
||||
"event": event_class,
|
||||
"authMethod": "header",
|
||||
"authData": {"Authorization": f"Bearer {secret}"},
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
}
|
||||
r = c.post(_ocs(base, "/webhooks"), headers=_ocs_headers(), json=body)
|
||||
r.raise_for_status()
|
||||
return r.json().get("ocs", {}).get("data", {})
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
base = os.environ.get("NEXTCLOUD_BASE_URL", "").rstrip("/")
|
||||
target = os.environ.get("NEXTCLOUD_WEBHOOK_TARGET", DEFAULT_TARGET)
|
||||
secret = os.environ.get("NEXTCLOUD_WEBHOOK_SECRET", "")
|
||||
if not base:
|
||||
logger.error("NEXTCLOUD_BASE_URL is not set")
|
||||
return 2
|
||||
if not secret:
|
||||
logger.error("NEXTCLOUD_WEBHOOK_SECRET is not set")
|
||||
return 2
|
||||
|
||||
user = await _pick_admin()
|
||||
if user is None:
|
||||
logger.error(
|
||||
"no mule user has nextcloud_app_password_enc set; can't auth to OCS"
|
||||
)
|
||||
return 2
|
||||
|
||||
nc_user = user.nextcloud_username or user.username
|
||||
pw = decrypt(user.nextcloud_app_password_enc)
|
||||
if not nc_user or not pw:
|
||||
logger.error("user %s has incomplete NC credentials", user.username)
|
||||
return 2
|
||||
|
||||
auth = httpx.BasicAuth(nc_user, pw)
|
||||
with httpx.Client(timeout=30, auth=auth, follow_redirects=False) as c:
|
||||
existing = _list_existing(c, base)
|
||||
logger.info("found %d existing webhook(s)", len(existing))
|
||||
|
||||
# Delete any pointing at the same target URI — idempotent rerun.
|
||||
for w in existing:
|
||||
if w.get("uri") == target:
|
||||
logger.info(
|
||||
"removing existing webhook id=%s event=%s",
|
||||
w.get("id"), w.get("event"),
|
||||
)
|
||||
_delete(c, base, str(w["id"]))
|
||||
|
||||
# Register fresh.
|
||||
for event_class in EVENTS:
|
||||
data = _register(c, base, target, secret, event_class)
|
||||
logger.info(
|
||||
"registered: id=%s event=%s",
|
||||
data.get("id"), event_class,
|
||||
)
|
||||
|
||||
logger.info("done — %d webhooks registered against %s", len(EVENTS), target)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,206 +0,0 @@
|
||||
# PhotoPrism stack — replaces the legacy mule-image backend over the course of
|
||||
# milestones M0–M5 (see /home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md).
|
||||
#
|
||||
# This compose file is intentionally separate from docker-compose.yml so the
|
||||
# legacy mule-image stack keeps running side-by-side until M5 cutover, when
|
||||
# data is migrated and the legacy backend is retired.
|
||||
#
|
||||
# docker compose -f docker-compose.photoprism.yml up -d
|
||||
#
|
||||
# M0 scope: mariadb + photoprism only. Library mounted READ-ONLY so initial
|
||||
# indexing cannot mutate originals while we validate. Backwrite, sidecar
|
||||
# service, web frontend, and reverse proxy land in later milestones.
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
# Fully-qualified for podman (which refuses short names by default).
|
||||
# Docker resolves the same digest.
|
||||
image: docker.io/library/mariadb:11
|
||||
container_name: pp-mariadb
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --innodb-buffer-pool-size=512M
|
||||
- --transaction-isolation=READ-COMMITTED
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --max-connections=512
|
||||
- --innodb-rollback-on-timeout=OFF
|
||||
- --innodb-lock-wait-timeout=120
|
||||
environment:
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
MARIADB_INITDB_SKIP_TZINFO: "1"
|
||||
MARIADB_DATABASE: ${PP_DB_NAME:-photoprism}
|
||||
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.photoprism}
|
||||
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env.photoprism}
|
||||
# Loopback-only host port so the mule-sidecar (running as a host process
|
||||
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
|
||||
# 127.0.0.1; the photoprism container still resolves mariadb by service
|
||||
# name on the photoprism-network bridge.
|
||||
ports:
|
||||
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- pp_mariadb_data:/var/lib/mysql
|
||||
# The init script creates the mule_sidecar database + user that the Go
|
||||
# sidecar service will use in M4. Idempotent; no-op on subsequent boots.
|
||||
# ":Z" is the SELinux private-relabel flag — needed on Fedora/RHEL hosts,
|
||||
# silently no-op on Debian/Ubuntu and macOS Docker Desktop.
|
||||
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
networks: [photoprism-network]
|
||||
|
||||
photoprism:
|
||||
image: docker.io/photoprism/photoprism:latest
|
||||
container_name: pp-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
# PhotoPrism's container drops to a non-root user via PHOTOPRISM_UID /
|
||||
# PHOTOPRISM_GID. Match the host user that owns ${PHOTO_DIRS} so the
|
||||
# process can read originals (and later write sidecars).
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
- apparmor:unconfined
|
||||
ports:
|
||||
- "${PP_PORT:-2342}:2342"
|
||||
environment:
|
||||
PHOTOPRISM_ADMIN_USER: ${PP_ADMIN_USER:-admin}
|
||||
PHOTOPRISM_ADMIN_PASSWORD: ${PP_ADMIN_PASSWORD:?set PP_ADMIN_PASSWORD in .env.photoprism}
|
||||
PHOTOPRISM_AUTH_MODE: ${PP_AUTH_MODE:-password}
|
||||
PHOTOPRISM_SITE_URL: ${PP_SITE_URL:-http://localhost:2342/}
|
||||
PHOTOPRISM_ORIGINALS_LIMIT: ${PP_ORIGINALS_LIMIT:-50000}
|
||||
PHOTOPRISM_HTTP_COMPRESSION: gzip
|
||||
PHOTOPRISM_LOG_LEVEL: ${PP_LOG_LEVEL:-info}
|
||||
# Indexer concurrency. Defaults to NumCPU/2 (= 3 on a 6-core LXC),
|
||||
# but each worker forks TF + ffmpeg + libvips so effective load is
|
||||
# much higher — a fresh index of 1.2k photos on M0 pushed the LXC
|
||||
# load to 50+ and starved sibling containers. Pin to a low value
|
||||
# for shared hosts; raise on dedicated machines.
|
||||
PHOTOPRISM_WORKERS: ${PP_WORKERS:-2}
|
||||
PHOTOPRISM_INDEX_WORKERS: ${PP_INDEX_WORKERS:-${PP_WORKERS:-2}}
|
||||
# M0 safety: keep originals read-only. Flip to "false" in M2 when the
|
||||
# right-sidebar enables metadata edits and we want EXIF backwrite.
|
||||
PHOTOPRISM_READONLY: ${PP_READONLY:-true}
|
||||
PHOTOPRISM_EXPERIMENTAL: "false"
|
||||
PHOTOPRISM_DISABLE_CHOWN: "true"
|
||||
PHOTOPRISM_DISABLE_WEBDAV: ${PP_DISABLE_WEBDAV:-false}
|
||||
PHOTOPRISM_DISABLE_SETTINGS: "false"
|
||||
PHOTOPRISM_DISABLE_TLS: "true"
|
||||
PHOTOPRISM_DEFAULT_TLS: "false"
|
||||
# AI/vision pipeline back on — per plan we re-introduce TF labels + faces.
|
||||
PHOTOPRISM_TENSORFLOW_OFF: "false"
|
||||
PHOTOPRISM_DETECT_NSFW: "true"
|
||||
PHOTOPRISM_UPLOAD_NSFW: "true"
|
||||
# Database
|
||||
PHOTOPRISM_DATABASE_DRIVER: mysql
|
||||
PHOTOPRISM_DATABASE_SERVER: mariadb:3306
|
||||
PHOTOPRISM_DATABASE_NAME: ${PP_DB_NAME:-photoprism}
|
||||
PHOTOPRISM_DATABASE_USER: ${PP_DB_USER:-photoprism}
|
||||
PHOTOPRISM_DATABASE_PASSWORD: ${PP_DB_PASSWORD}
|
||||
# Sidecars next to originals — read by the migrator at M5.
|
||||
PHOTOPRISM_SIDECAR_PATH: ""
|
||||
PHOTOPRISM_SIDECAR_YAML: "true"
|
||||
# EXIF backwrite — disabled in M0 (READONLY blocks writes anyway).
|
||||
# Re-enable in M2 by overriding in .env.photoprism: PP_BACKUP_DATABASE=true.
|
||||
PHOTOPRISM_DISABLE_BACKUPS: "false"
|
||||
PHOTOPRISM_BACKUP_DATABASE: ${PP_BACKUP_DATABASE:-true}
|
||||
PHOTOPRISM_DISABLE_EXIFTOOL: "false"
|
||||
# OIDC — set in .env.photoprism when the IdP (Authentik) is wired up.
|
||||
# Empty values keep OIDC dormant; the username/password login still works.
|
||||
# PhotoPrism's CLI flags are --oidc-uri / --oidc-client / --oidc-secret
|
||||
# / --oidc-provider, so the env-var names it actually reads are
|
||||
# PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER (NOT _ISSUER_URL
|
||||
# / _CLIENT_ID / _CLIENT_SECRET / _PROVIDER_NAME — those are silently
|
||||
# ignored, OIDC stays dormant, and `photoprism show config` reports
|
||||
# blank oidc-uri / oidc-client). PHOTOPRISM_OIDC_REDIRECT is a bool
|
||||
# (auto-redirect-from-/library/login), not a URL — PhotoPrism builds
|
||||
# the callback from PHOTOPRISM_SITE_URL.
|
||||
PHOTOPRISM_OIDC_PROVIDER: ${OIDC_PROVIDER_NAME:-${OIDC_PROVIDER:-}}
|
||||
PHOTOPRISM_OIDC_URI: ${OIDC_ISSUER_URL:-${OIDC_URI:-}}
|
||||
PHOTOPRISM_OIDC_CLIENT: ${OIDC_CLIENT_ID:-${OIDC_CLIENT:-}}
|
||||
PHOTOPRISM_OIDC_SECRET: ${OIDC_CLIENT_SECRET:-${OIDC_SECRET:-}}
|
||||
PHOTOPRISM_OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||
PHOTOPRISM_OIDC_REGISTER: ${OIDC_REGISTER:-true}
|
||||
PHOTOPRISM_OIDC_ROLE: ${OIDC_ROLE:-user}
|
||||
PHOTOPRISM_OIDC_REDIRECT: ${OIDC_REDIRECT:-false}
|
||||
working_dir: /photoprism
|
||||
volumes:
|
||||
# Existing photo library — mounted read-only in M0; flip to :rw in M2
|
||||
# when the right-sidebar starts saving edits. ",Z" relabels for SELinux
|
||||
# on Fedora/RHEL; silent no-op elsewhere.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env.photoprism}:/photoprism/originals:${PP_ORIGINALS_MODE:-ro},Z"
|
||||
- "./pp/storage:/photoprism/storage:Z"
|
||||
- "./pp/import:/photoprism/import:Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
# mule-sidecar — Go + Gin + GORM service for endpoints PhotoPrism's API
|
||||
# does not expose (file rename, folder mutations, heap convert, duplicate
|
||||
# scan, per-photo marks). Same wire contract as the M3 Node prototype;
|
||||
# the SvelteKit dev server proxies /api/sidecar/* here.
|
||||
sidecar:
|
||||
build:
|
||||
context: ./sidecar
|
||||
container_name: pp-sidecar
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
photoprism:
|
||||
condition: service_started
|
||||
# Match PhotoPrism's UID/GID so renames/folder mutations preserve the
|
||||
# ownership the indexer expects on the bind-mounted originals.
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
ports:
|
||||
# Loopback only — Vite (host) proxies /api/sidecar/* to this port.
|
||||
# Behind a reverse proxy in production; never published beyond the
|
||||
# host.
|
||||
- "127.0.0.1:${SIDECAR_PORT:-8000}:8000"
|
||||
environment:
|
||||
ORIGINALS_ROOT: /photoprism/originals
|
||||
PHOTOPRISM_BASE_URL: http://photoprism:2342
|
||||
# Bind on all interfaces inside the container so the host-side
|
||||
# 127.0.0.1:8000 port mapping can reach the listener. The Go
|
||||
# binary defaults to 127.0.0.1 for the host-mode dev loop.
|
||||
SIDECAR_LISTEN_ADDR: 0.0.0.0
|
||||
SIDECAR_PORT: "8000"
|
||||
SIDECAR_DB_HOST: mariadb
|
||||
SIDECAR_DB_PORT: "3306"
|
||||
SIDECAR_DB_USER: sidecar
|
||||
# Rotate before any non-local deployment. Provisioned by
|
||||
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
|
||||
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
|
||||
SIDECAR_DB_NAME: mule_sidecar
|
||||
# Second DB connection for poking PhotoPrism's own schema (only
|
||||
# used by the user-basepath reconciler today). Stays inert if
|
||||
# PP_DB_PASSWORD is empty — the reconciler then silently no-ops.
|
||||
PP_DB_HOST: mariadb
|
||||
PP_DB_PORT: "3306"
|
||||
PP_DB_USER: ${PP_DB_USER:-photoprism}
|
||||
PP_DB_PASSWORD: ${PP_DB_PASSWORD:-}
|
||||
PP_DB_NAME: ${PP_DB_NAME:-photoprism}
|
||||
# Declarative username → originals-relative BasePath mapping.
|
||||
# Format: comma-separated `user:path` pairs. Sidecar applies it
|
||||
# to auth_users on boot and every 60s, and `mkdir -p`s each
|
||||
# target subdirectory so PhotoPrism's ACL filter has somewhere to
|
||||
# point. Leave empty to disable.
|
||||
# USER_BASEPATHS="test:test, alice:family/alice"
|
||||
USER_BASEPATHS: ${USER_BASEPATHS:-}
|
||||
volumes:
|
||||
# Sidecar mutates originals (rename, folder mutations, heap
|
||||
# convert) — always rw regardless of PhotoPrism's mount mode.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env.photoprism}:/photoprism/originals:rw,Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
networks:
|
||||
photoprism-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pp_mariadb_data:
|
||||
@@ -1,9 +1,9 @@
|
||||
# Podman-rootless overlay for the PhotoPrism stack.
|
||||
#
|
||||
# Apply alongside the base compose file:
|
||||
# podman-compose --env-file .env.photoprism \
|
||||
# -f docker-compose.photoprism.yml \
|
||||
# -f docker-compose.photoprism.podman.yml \
|
||||
# podman-compose --env-file .env \
|
||||
# -f docker-compose.yml \
|
||||
# -f docker-compose.podman.yml \
|
||||
# up -d
|
||||
#
|
||||
# Adds the podman-specific bits that would break a vanilla docker compose run:
|
||||
@@ -1,50 +0,0 @@
|
||||
# SQLite escape hatch override.
|
||||
#
|
||||
# Usage (omit the `db` service from the up command):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.sqlite.yml \
|
||||
# up frontend backend worker redis
|
||||
#
|
||||
# This pins the backend and worker to the legacy SQLite database file at
|
||||
# /data/db/mulita.db (in the existing db_data volume), drops the dependency
|
||||
# on Postgres, and skips Alembic — the SQLite schema is still managed by
|
||||
# the inline ALTERs in app/database.py:init_db.
|
||||
#
|
||||
# Vision features that depend on pgvector (PR4 onward) will refuse to enable
|
||||
# in this mode; the search/embedding endpoints will return 503 with a clear
|
||||
# error pointing back at the default Postgres setup.
|
||||
|
||||
services:
|
||||
backend:
|
||||
command: sh -c "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||||
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
worker:
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||
- CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_started
|
||||
@@ -1,268 +1,209 @@
|
||||
# Compose stack for the PhotoPrism-backed photo app: mariadb + photoprism +
|
||||
# Go sidecar. The SvelteKit web/ frontend runs separately (Vite in dev,
|
||||
# static build in prod) and proxies /api/v1/* to photoprism and
|
||||
# /api/sidecar/* to the sidecar.
|
||||
#
|
||||
# podman-compose --env-file .env \
|
||||
# -f docker-compose.yml -f docker-compose.podman.yml up -d
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-frontend
|
||||
ports:
|
||||
# Host port is configurable via FRONTEND_PORT in .env so multiple
|
||||
# instances / other services on the same host don't collide.
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- mulita-network
|
||||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||||
# docker DNS forwards the lookup to the host's resolver, which
|
||||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||||
# traefik exposure list, so TLS handshakes against it die with
|
||||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||||
extra_hosts:
|
||||
- "cloud.hubris.network:192.168.8.175"
|
||||
mariadb:
|
||||
# Fully-qualified for podman (which refuses short names by default).
|
||||
# Docker resolves the same digest.
|
||||
image: docker.io/library/mariadb:11
|
||||
container_name: pp-mariadb
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-backend
|
||||
ports:
|
||||
# Direct backend access on the host is rarely needed (the frontend
|
||||
# talks to it through the nginx /api proxy on the same network),
|
||||
# but it's exposed for debugging / curl. Override with BACKEND_PORT.
|
||||
- "${BACKEND_PORT:-8001}:8000"
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
# The single host → container mount for your photo library. Set
|
||||
# PHOTO_DIRS in .env to your library root. Mounted :rw because file
|
||||
# operations (rename, move, empty discard pile) need to mutate the
|
||||
# filesystem; flip to :ro for a strict read-only library and the
|
||||
# write endpoints will return EROFS.
|
||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||
# Optional Nextcloud integration: mount the homecloud data dir so
|
||||
# users can register subfolders of their Nextcloud `files/` tree
|
||||
# as per-user SourceRoots. Reads use this path directly; mutations
|
||||
# (upload, delete, rename, move) dispatch via WebDAV against
|
||||
# NEXTCLOUD_BASE_URL so Nextcloud's oc_filecache stays in sync.
|
||||
# Leave NEXTCLOUD_USERS_HOST_PATH unset (or pointing at a no-op
|
||||
# path) to disable.
|
||||
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||||
- thumbs_data:/data/thumbs
|
||||
- proxies_data:/data/proxies
|
||||
- video_cache_data:/data/video-cache
|
||||
- db_data:/data/db # retained so the docker-compose.sqlite.yml override has somewhere to put mulita.db
|
||||
# Run Alembic migrations before starting uvicorn. On a fresh Postgres
|
||||
# the empty 0001 baseline is a no-op stamp; create_all in init_db then
|
||||
# builds the schema.
|
||||
# init_db creates all tables from models (idempotent create_all),
|
||||
# then Alembic runs migrations for existing installs. On fresh DBs
|
||||
# create_all already built the full schema, so bootstrap.py stamps
|
||||
# alembic head to skip redundant ALTER statements.
|
||||
command: sh -c "python -c 'import asyncio; from app.database import init_db; asyncio.run(init_db())' && python bootstrap.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 --proxy-headers"
|
||||
command:
|
||||
- --innodb-buffer-pool-size=512M
|
||||
- --transaction-isolation=READ-COMMITTED
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --max-connections=512
|
||||
- --innodb-rollback-on-timeout=OFF
|
||||
- --innodb-lock-wait-timeout=120
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=/photos
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||||
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
# Authentik / OIDC single sign-on. Leave OIDC_ENABLED=false to
|
||||
# hide the SSO button and stick with username/password. When
|
||||
# enabled, set OIDC_ISSUER to the Authentik provider URL (the one
|
||||
# that serves /.well-known/openid-configuration), and paste the
|
||||
# client id/secret from the Authentik application. OIDC_REDIRECT_URI
|
||||
# must match the one registered on the Authentik side exactly —
|
||||
# e.g. https://photovault.example.com/api/v1/auth/oidc/callback.
|
||||
- OIDC_ENABLED=${OIDC_ENABLED:-false}
|
||||
- OIDC_ISSUER=${OIDC_ISSUER:-}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
||||
- OIDC_SCOPES=${OIDC_SCOPES:-openid profile email}
|
||||
- OIDC_PROVIDER_LABEL=${OIDC_PROVIDER_LABEL:-Authentik}
|
||||
- OIDC_ALLOW_SIGNUP=${OIDC_ALLOW_SIGNUP:-true}
|
||||
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
|
||||
- OIDC_LINK_BY_USERNAME=${OIDC_LINK_BY_USERNAME:-false}
|
||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||
# Nextcloud integration. NEXTCLOUD_USERS_ROOT is the in-container
|
||||
# path that NEXTCLOUD_USERS_HOST_PATH binds to. NEXTCLOUD_BASE_URL
|
||||
# is the public-facing Nextcloud URL used for outgoing WebDAV
|
||||
# calls (must be reachable from the backend container; e.g.
|
||||
# https://cloud.example.com or http://nextcloud:80 if you put it
|
||||
# on the same docker network). Leave NEXTCLOUD_BASE_URL unset to
|
||||
# keep the integration off — the router endpoints stay registered
|
||||
# but mutating endpoints fail with a clear error.
|
||||
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||||
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||||
- NEXTCLOUD_WEBHOOK_SECRET=${NEXTCLOUD_WEBHOOK_SECRET:-}
|
||||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mulita-network
|
||||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||||
# docker DNS forwards the lookup to the host's resolver, which
|
||||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||||
# traefik exposure list, so TLS handshakes against it die with
|
||||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||||
extra_hosts:
|
||||
- "cloud.hubris.network:192.168.8.175"
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Celery workers ─────────────────────────────────────────────────────
|
||||
#
|
||||
# The ingestion pipeline is split across two worker services so CPU-heavy
|
||||
# vision tasks (embed / detect / OCR / faces / classify) cannot starve
|
||||
# the fast IO-bound tasks (scan / thumbnails / EXIF / phash / duplicates).
|
||||
#
|
||||
# worker-light listens on default,high,low — IO-bound, cheap
|
||||
# worker-vision listens on vision — CPU-bound, loads ONNX
|
||||
#
|
||||
# Both share the same image, photo volume, and model cache, so there's
|
||||
# no disk duplication and model weights are loaded lazily only by
|
||||
# worker-vision. Each service has its own concurrency knob; both
|
||||
# workers ship their heartbeat to the same Redis broker so the
|
||||
# Settings > Workers panel lists them side-by-side.
|
||||
#
|
||||
# Sizing defaults target a 6-core / 16 GB host:
|
||||
# CELERY_LIGHT_CONCURRENCY=2 (enough for parallel thumbnail + EXIF)
|
||||
# CELERY_VISION_CONCURRENCY=5 (5 × ~2GB ONNX = ~10GB RAM, 5/6 cores)
|
||||
# Raise these in .env and run `docker compose up -d worker-light worker-vision`
|
||||
# to scale. Keep light under ~4 and vision under your physical core
|
||||
# count; more just thrashes.
|
||||
worker-light:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
image: mule-image-worker
|
||||
container_name: mulita-worker-light
|
||||
command: sh -c "celery -A app.tasks.celery worker --beat --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERY_LIGHT_CONCURRENCY:-2} -Q default,high,low -n light@%h"
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||||
- thumbs_data:/data/thumbs
|
||||
- proxies_data:/data/proxies
|
||||
- video_cache_data:/data/video-cache
|
||||
- db_data:/data/db
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=/photos
|
||||
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||||
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||||
- NEXTCLOUD_WEBHOOK_SECRET=${NEXTCLOUD_WEBHOOK_SECRET:-}
|
||||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
# NullPool — see app/database.py for rationale.
|
||||
- MULITA_CELERY_WORKER=1
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "celery -A app.tasks.celery inspect ping -d light@$$HOSTNAME 2>/dev/null | grep -q OK"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
networks:
|
||||
- mulita-network
|
||||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||||
# docker DNS forwards the lookup to the host's resolver, which
|
||||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||||
# traefik exposure list, so TLS handshakes against it die with
|
||||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||||
extra_hosts:
|
||||
- "cloud.hubris.network:192.168.8.175"
|
||||
restart: unless-stopped
|
||||
|
||||
# worker-watcher used to live here — it ran the long-lived
|
||||
# watchfiles-based `watch_folders` task plus celery `--beat`. Both
|
||||
# responsibilities moved on Phase 2:
|
||||
# * file events now come from NC's webhook_listeners → POST
|
||||
# /api/v1/internal/nc-webhook (see backend/app/routers/nc_webhook.py)
|
||||
# * `--beat` was folded into worker-light's command so the
|
||||
# periodic discard_missing_photos_beat job still fires.
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: mulita-db
|
||||
environment:
|
||||
POSTGRES_USER: mulita
|
||||
POSTGRES_PASSWORD: mulita
|
||||
POSTGRES_DB: mulita
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- mulita-network
|
||||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||||
# docker DNS forwards the lookup to the host's resolver, which
|
||||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||||
# traefik exposure list, so TLS handshakes against it die with
|
||||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||||
extra_hosts:
|
||||
- "cloud.hubris.network:192.168.8.175"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mulita -d mulita"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mulita-redis
|
||||
# Host port exposed only for local debugging; the backend / worker
|
||||
# reach Redis via the internal mulita-network on its container name.
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
MARIADB_INITDB_SKIP_TZINFO: "1"
|
||||
MARIADB_DATABASE: ${PP_DB_NAME:-photoprism}
|
||||
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env}
|
||||
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env}
|
||||
# Loopback-only host port so the mule-sidecar (running as a host process
|
||||
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
|
||||
# 127.0.0.1; the photoprism container still resolves mariadb by service
|
||||
# name on the photoprism-network bridge.
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- mulita-network
|
||||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||||
# docker DNS forwards the lookup to the host's resolver, which
|
||||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||||
# traefik exposure list, so TLS handshakes against it die with
|
||||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||||
extra_hosts:
|
||||
- "cloud.hubris.network:192.168.8.175"
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
- pp_mariadb_data:/var/lib/mysql
|
||||
# The init script creates the mule_sidecar database + user that the Go
|
||||
# sidecar service will use in M4. Idempotent; no-op on subsequent boots.
|
||||
# ":Z" is the SELinux private-relabel flag — needed on Fedora/RHEL hosts,
|
||||
# silently no-op on Debian/Ubuntu and macOS Docker Desktop.
|
||||
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
networks: [photoprism-network]
|
||||
|
||||
photoprism:
|
||||
image: docker.io/photoprism/photoprism:latest
|
||||
container_name: pp-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
# PhotoPrism's container drops to a non-root user via PHOTOPRISM_UID /
|
||||
# PHOTOPRISM_GID. Match the host user that owns ${PHOTO_DIRS} so the
|
||||
# process can read originals (and later write sidecars).
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
- apparmor:unconfined
|
||||
ports:
|
||||
# Loopback only — the SvelteKit web/ app (Vite dev or built bundle)
|
||||
# is the user-facing surface; PhotoPrism's own UI stays off the
|
||||
# public interface. Vite proxies /api/v1/* here, and the host-mode
|
||||
# sidecar reaches PHOTOPRISM_BASE_URL=http://localhost:2342. Admin
|
||||
# access to PP's UI is via SSH tunnel only.
|
||||
- "127.0.0.1:${PP_PORT:-2342}:2342"
|
||||
environment:
|
||||
PHOTOPRISM_ADMIN_USER: ${PP_ADMIN_USER:-admin}
|
||||
PHOTOPRISM_ADMIN_PASSWORD: ${PP_ADMIN_PASSWORD:?set PP_ADMIN_PASSWORD in .env}
|
||||
PHOTOPRISM_AUTH_MODE: ${PP_AUTH_MODE:-password}
|
||||
PHOTOPRISM_SITE_URL: ${PP_SITE_URL:-http://localhost:2342/}
|
||||
PHOTOPRISM_ORIGINALS_LIMIT: ${PP_ORIGINALS_LIMIT:-50000}
|
||||
PHOTOPRISM_HTTP_COMPRESSION: gzip
|
||||
PHOTOPRISM_LOG_LEVEL: ${PP_LOG_LEVEL:-info}
|
||||
# Indexer concurrency. Defaults to NumCPU/2 (= 3 on a 6-core LXC),
|
||||
# but each worker forks TF + ffmpeg + libvips so effective load is
|
||||
# much higher — a fresh index of 1.2k photos on M0 pushed the LXC
|
||||
# load to 50+ and starved sibling containers. Pin to a low value
|
||||
# for shared hosts; raise on dedicated machines.
|
||||
PHOTOPRISM_WORKERS: ${PP_WORKERS:-2}
|
||||
# podman-compose doesn't expand nested ${A:-${B:-…}}, so keep this
|
||||
# one-level. Override both PP_WORKERS and PP_INDEX_WORKERS if you
|
||||
# want them to differ.
|
||||
PHOTOPRISM_INDEX_WORKERS: ${PP_INDEX_WORKERS:-2}
|
||||
# M0 safety: keep originals read-only. Flip to "false" in M2 when the
|
||||
# right-sidebar enables metadata edits and we want EXIF backwrite.
|
||||
PHOTOPRISM_READONLY: ${PP_READONLY:-true}
|
||||
PHOTOPRISM_EXPERIMENTAL: "false"
|
||||
PHOTOPRISM_DISABLE_CHOWN: "true"
|
||||
PHOTOPRISM_DISABLE_WEBDAV: ${PP_DISABLE_WEBDAV:-false}
|
||||
PHOTOPRISM_DISABLE_SETTINGS: "false"
|
||||
PHOTOPRISM_DISABLE_TLS: "true"
|
||||
PHOTOPRISM_DEFAULT_TLS: "false"
|
||||
# AI/vision pipeline back on — per plan we re-introduce TF labels + faces.
|
||||
PHOTOPRISM_TENSORFLOW_OFF: "false"
|
||||
PHOTOPRISM_DETECT_NSFW: "true"
|
||||
PHOTOPRISM_UPLOAD_NSFW: "true"
|
||||
# Database
|
||||
PHOTOPRISM_DATABASE_DRIVER: mysql
|
||||
PHOTOPRISM_DATABASE_SERVER: mariadb:3306
|
||||
PHOTOPRISM_DATABASE_NAME: ${PP_DB_NAME:-photoprism}
|
||||
PHOTOPRISM_DATABASE_USER: ${PP_DB_USER:-photoprism}
|
||||
PHOTOPRISM_DATABASE_PASSWORD: ${PP_DB_PASSWORD}
|
||||
# Sidecars next to originals — read by the migrator at M5.
|
||||
PHOTOPRISM_SIDECAR_PATH: ""
|
||||
PHOTOPRISM_SIDECAR_YAML: "true"
|
||||
# EXIF backwrite — disabled in M0 (READONLY blocks writes anyway).
|
||||
# Override in .env: PP_BACKUP_DATABASE=true.
|
||||
PHOTOPRISM_DISABLE_BACKUPS: "false"
|
||||
PHOTOPRISM_BACKUP_DATABASE: ${PP_BACKUP_DATABASE:-true}
|
||||
PHOTOPRISM_DISABLE_EXIFTOOL: "false"
|
||||
# OIDC — set in .env when the IdP (Authentik) is wired up.
|
||||
# Empty values keep OIDC dormant; the username/password login still works.
|
||||
# PhotoPrism's CLI flags are --oidc-uri / --oidc-client / --oidc-secret
|
||||
# / --oidc-provider, so the env-var names it actually reads are
|
||||
# PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER (NOT _ISSUER_URL
|
||||
# / _CLIENT_ID / _CLIENT_SECRET / _PROVIDER_NAME — those are silently
|
||||
# ignored, OIDC stays dormant, and `photoprism show config` reports
|
||||
# blank oidc-uri / oidc-client). PHOTOPRISM_OIDC_REDIRECT is a bool
|
||||
# (auto-redirect-from-/library/login), not a URL — PhotoPrism builds
|
||||
# the callback from PHOTOPRISM_SITE_URL.
|
||||
PHOTOPRISM_OIDC_PROVIDER: ${OIDC_PROVIDER_NAME:-${OIDC_PROVIDER:-}}
|
||||
PHOTOPRISM_OIDC_URI: ${OIDC_ISSUER_URL:-${OIDC_URI:-}}
|
||||
PHOTOPRISM_OIDC_CLIENT: ${OIDC_CLIENT_ID:-${OIDC_CLIENT:-}}
|
||||
PHOTOPRISM_OIDC_SECRET: ${OIDC_CLIENT_SECRET:-${OIDC_SECRET:-}}
|
||||
PHOTOPRISM_OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||
PHOTOPRISM_OIDC_REGISTER: ${OIDC_REGISTER:-true}
|
||||
PHOTOPRISM_OIDC_ROLE: ${OIDC_ROLE:-user}
|
||||
PHOTOPRISM_OIDC_REDIRECT: ${OIDC_REDIRECT:-false}
|
||||
working_dir: /photoprism
|
||||
volumes:
|
||||
# Existing photo library — mounted read-only in M0; flip to :rw in M2
|
||||
# when the right-sidebar starts saving edits. ",Z" relabels for SELinux
|
||||
# on Fedora/RHEL; silent no-op elsewhere.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:${PP_ORIGINALS_MODE:-ro},Z"
|
||||
- "./pp/storage:/photoprism/storage:Z"
|
||||
- "./pp/import:/photoprism/import:Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
# mule-sidecar — Go + Gin + GORM service for endpoints PhotoPrism's API
|
||||
# does not expose (file rename, folder mutations, heap convert, duplicate
|
||||
# scan, per-photo marks). Same wire contract as the M3 Node prototype;
|
||||
# the SvelteKit dev server proxies /api/sidecar/* here.
|
||||
sidecar:
|
||||
build:
|
||||
context: ./sidecar
|
||||
container_name: pp-sidecar
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
photoprism:
|
||||
condition: service_started
|
||||
# Match PhotoPrism's UID/GID so renames/folder mutations preserve the
|
||||
# ownership the indexer expects on the bind-mounted originals.
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
ports:
|
||||
# Loopback only — Vite (host) proxies /api/sidecar/* to this port.
|
||||
# Behind a reverse proxy in production; never published beyond the
|
||||
# host.
|
||||
- "127.0.0.1:${SIDECAR_PORT:-8000}:8000"
|
||||
environment:
|
||||
ORIGINALS_ROOT: /photoprism/originals
|
||||
PHOTOPRISM_BASE_URL: http://photoprism:2342
|
||||
# Bind on all interfaces inside the container so the host-side
|
||||
# 127.0.0.1:8000 port mapping can reach the listener. The Go
|
||||
# binary defaults to 127.0.0.1 for the host-mode dev loop.
|
||||
SIDECAR_LISTEN_ADDR: 0.0.0.0
|
||||
SIDECAR_PORT: "8000"
|
||||
SIDECAR_DB_HOST: mariadb
|
||||
SIDECAR_DB_PORT: "3306"
|
||||
SIDECAR_DB_USER: sidecar
|
||||
# Rotate before any non-local deployment. Provisioned by
|
||||
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
|
||||
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
|
||||
SIDECAR_DB_NAME: mule_sidecar
|
||||
# Second DB connection for poking PhotoPrism's own schema (only
|
||||
# used by the user-basepath reconciler today). Stays inert if
|
||||
# PP_DB_PASSWORD is empty — the reconciler then silently no-ops.
|
||||
PP_DB_HOST: mariadb
|
||||
PP_DB_PORT: "3306"
|
||||
PP_DB_USER: ${PP_DB_USER:-photoprism}
|
||||
PP_DB_PASSWORD: ${PP_DB_PASSWORD:-}
|
||||
PP_DB_NAME: ${PP_DB_NAME:-photoprism}
|
||||
# Declarative username → originals-relative BasePath mapping.
|
||||
# Format: comma-separated `user:path` pairs. Sidecar applies it
|
||||
# to auth_users on boot and every 60s, and `mkdir -p`s each
|
||||
# target subdirectory so PhotoPrism's ACL filter has somewhere to
|
||||
# point. Leave empty to disable.
|
||||
# USER_BASEPATHS="test:test, alice:family/alice"
|
||||
USER_BASEPATHS: ${USER_BASEPATHS:-}
|
||||
volumes:
|
||||
# Sidecar mutates originals (rename, folder mutations, heap
|
||||
# convert) — always rw regardless of PhotoPrism's mount mode.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:rw,Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
networks:
|
||||
mulita-network:
|
||||
photoprism-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
thumbs_data:
|
||||
proxies_data:
|
||||
video_cache_data:
|
||||
db_data:
|
||||
redis_data:
|
||||
pg_data:
|
||||
pp_mariadb_data:
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f0f0f" />
|
||||
<title>Mulimago</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support for real-time updates
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Thumbnail serving with X-Accel-Redirect
|
||||
location /internal_thumbs/ {
|
||||
internal;
|
||||
alias /data/thumbs/;
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never cache index.html (or any HTML). The asset filenames are
|
||||
# content-hashed by Vite, so a fresh index.html is the only thing
|
||||
# that tells the browser to fetch the new bundle. Without this the
|
||||
# browser happily serves a stale index.html → stale bundle hash →
|
||||
# users see the old build until they hard-reload.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# Cache static assets (filenames are content-hashed, so 1y is safe)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
6288
frontend/package-lock.json
generated
6288
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"name": "mulita-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.1.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"@tanstack/react-virtual": "^3.0.1",
|
||||
"axios": "^1.6.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.2.0",
|
||||
"framer-motion": "^10.18.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.303.0",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.4.3",
|
||||
"react-intersection-observer": "^9.5.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 821 KiB |
@@ -1,209 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Timeline } from './components/timeline/Timeline'
|
||||
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
||||
import { MapView } from './components/map/MapView'
|
||||
import { MemoriesView } from './components/memories/MemoriesView'
|
||||
import { TagsView } from './components/tags/TagsView'
|
||||
import { ColorsView } from './components/colors/ColorsView'
|
||||
import { RatedView } from './components/rated/RatedView'
|
||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||
import { RightSidebar } from './components/layout/RightSidebar'
|
||||
import { TopBar } from './components/layout/TopBar'
|
||||
import { ScanProgress } from './components/ScanProgress'
|
||||
import { ToastContainer } from './components/ToastContainer'
|
||||
import { KeyboardHints } from './components/KeyboardHints'
|
||||
import { PreviewView } from './components/preview/PreviewView'
|
||||
import { FilterBar } from './components/filter/FilterBar'
|
||||
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||
import { SettingsPage } from './components/dialogs/SettingsDialog'
|
||||
import { usePhotoStore } from './store/photoStore'
|
||||
import { useFilterStore } from './store/filterStore'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||
import { usePhotosQuery } from './hooks/usePhotosQuery'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { LoginPage } from './components/auth/LoginPage'
|
||||
import { SetupPage } from './components/auth/SetupPage'
|
||||
import { OidcCallback } from './components/auth/OidcCallback'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
function MainApp() {
|
||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
||||
// Respect the user's manual collapse of the metadata panel. Once they
|
||||
// close it explicitly (via `i` hotkey or the sidebar toggle button),
|
||||
// selecting a new photo should NOT force it back open. Cleared when
|
||||
// they open it manually again.
|
||||
const rightCollapsedByUser = useRef(false)
|
||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
|
||||
// Close the metadata panel when the user switches between sections so
|
||||
// it doesn't carry over a now-irrelevant selection. It re-opens once a
|
||||
// photo gains focus in the new section (effect below).
|
||||
const prevSectionRef = useRef(currentSection)
|
||||
useEffect(() => {
|
||||
if (prevSectionRef.current !== currentSection) {
|
||||
prevSectionRef.current = currentSection
|
||||
setRightSidebarOpen(false)
|
||||
}
|
||||
}, [currentSection])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePhotoId) {
|
||||
setRightSidebarOpen(false)
|
||||
return
|
||||
}
|
||||
// User explicitly collapsed the panel — don't undo that just because
|
||||
// they picked a different photo.
|
||||
if (rightCollapsedByUser.current) return
|
||||
setRightSidebarOpen(true)
|
||||
}, [activePhotoId])
|
||||
|
||||
const toggleRightSidebar = () => {
|
||||
setRightSidebarOpen((prev) => {
|
||||
const next = !prev
|
||||
rightCollapsedByUser.current = !next
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Bidirectional sync of filter store with URL query params.
|
||||
useFilterUrlSync()
|
||||
|
||||
// Subscribe to the same photos query the Timeline uses, so the keyboard
|
||||
// "open preview on first photo" path can read from the live cache regardless
|
||||
// of what filter key it's stored under.
|
||||
const { data: allPhotos } = usePhotosQuery()
|
||||
|
||||
// Set up global keyboard shortcuts. Prefer the Timeline's published
|
||||
// visible sequence (which respects per-month ordering) over the raw
|
||||
// backend list — otherwise "Space on a blank selection" would open
|
||||
// the globally first photo, which isn't what the user sees at the
|
||||
// top-left of the grid.
|
||||
useKeyboardShortcuts({
|
||||
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
||||
onToggleRightSidebar: toggleRightSidebar,
|
||||
getFirstPhotoId: () =>
|
||||
usePhotoStore.getState().visiblePhotoIds[0] ??
|
||||
allPhotos?.[0]?.id ??
|
||||
null,
|
||||
})
|
||||
|
||||
// Settings page is a full-page section — hide filter bar, right sidebar,
|
||||
// and keyboard hints when it's active.
|
||||
const isSettings = currentSection === 'settings'
|
||||
|
||||
// Right sidebar stays open by default and shows whatever's selected
|
||||
// (or an empty state if nothing is). User can still toggle it manually.
|
||||
const showRightSidebar = rightSidebarOpen && !isSettings
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-col h-screen bg-bg text-text">
|
||||
<TopBar />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
leftSidebarOpen ? 'w-60' : 'w-0'
|
||||
} overflow-hidden border-r border-border bg-surface`}
|
||||
>
|
||||
<LeftSidebar />
|
||||
</div>
|
||||
|
||||
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
||||
* right of the left sidebar so the filter row doesn't bleed
|
||||
* across the sidebar. relative so the KeyboardHints overlay
|
||||
* centers against this column, not the viewport. */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
{!isSettings && (
|
||||
<FilterBar
|
||||
leftSidebarOpen={leftSidebarOpen}
|
||||
rightSidebarOpen={showRightSidebar}
|
||||
onToggleLeftSidebar={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
||||
onToggleRightSidebar={toggleRightSidebar}
|
||||
/>
|
||||
)}
|
||||
{!isSettings && <DiscardActionBar />}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{currentSection === 'settings' ? (
|
||||
<SettingsPage />
|
||||
) : currentSection === 'map' ? (
|
||||
<MapView />
|
||||
) : currentSection === 'memories' ? (
|
||||
<MemoriesView />
|
||||
) : currentSection === 'duplicates' ? (
|
||||
<DuplicatesView />
|
||||
) : currentSection === 'tags' ? (
|
||||
<TagsView />
|
||||
) : currentSection === 'colors' ? (
|
||||
<ColorsView />
|
||||
) : currentSection === 'rated' ? (
|
||||
<RatedView />
|
||||
) : (
|
||||
<Timeline />
|
||||
)}
|
||||
</div>
|
||||
{!isSettings && viewMode !== 'preview' && <KeyboardHints />}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
showRightSidebar ? 'w-72' : 'w-0'
|
||||
} overflow-hidden border-l border-border bg-surface`}
|
||||
>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan Progress Indicator */}
|
||||
<ScanProgress />
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer />
|
||||
|
||||
{/* Preview overlay — covers TopBar when active */}
|
||||
{viewMode === 'preview' && <PreviewView />}
|
||||
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Auth-gated shell: shows setup, login, or the main app. */
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthGate />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const { user, isLoading, needsSetup } = useAuth()
|
||||
|
||||
// OIDC callback lands on /auth/callback — handle it even while
|
||||
// isLoading, so the callback page can adopt tokens and transition
|
||||
// straight to MainApp without flashing the login screen.
|
||||
if (window.location.pathname.startsWith('/auth/callback')) {
|
||||
return <OidcCallback />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg">
|
||||
<div className="text-text-muted">Loading…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (needsSetup) return <SetupPage />
|
||||
if (!user) return <LoginPage />
|
||||
return <MainApp />
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 646 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 821 KiB |
@@ -1,136 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
import { useFilterStore } from '../store/filterStore'
|
||||
|
||||
const STORAGE_KEY = 'keyboard-hints-collapsed'
|
||||
|
||||
interface Hint {
|
||||
key: string
|
||||
action: string
|
||||
}
|
||||
|
||||
/** Build the hint list for the current context. Returns an empty array
|
||||
* when no shortcuts apply, which lets the caller hide the panel
|
||||
* entirely instead of rendering an empty pill. */
|
||||
function getHints(opts: {
|
||||
selectedCount: number
|
||||
currentSection: string
|
||||
viewMode: string
|
||||
}): Hint[] {
|
||||
const { selectedCount, currentSection, viewMode } = opts
|
||||
|
||||
// Preview mode: culling shortcuts apply to the photo on screen, plus
|
||||
// arrow nav between photos and Esc to close.
|
||||
if (viewMode === 'preview') {
|
||||
const preview: Hint[] = [
|
||||
{ key: '←→', action: 'Navigate' },
|
||||
{ key: '1-5', action: 'Rate' },
|
||||
{ key: 'S', action: 'Select → heap' },
|
||||
]
|
||||
if (currentSection === 'discarded') {
|
||||
preview.push({ key: 'U', action: 'Restore' })
|
||||
} else {
|
||||
preview.push({ key: 'X', action: 'Discard' })
|
||||
}
|
||||
preview.push(
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
{ key: 'Space', action: 'Close' },
|
||||
{ key: 'Esc', action: 'Close' }
|
||||
)
|
||||
return preview
|
||||
}
|
||||
|
||||
if (selectedCount > 0) {
|
||||
const base: Hint[] = [
|
||||
{ key: '1-5', action: 'Rate' },
|
||||
{ key: 'S', action: 'Select → heap' },
|
||||
]
|
||||
if (currentSection === 'discarded') {
|
||||
base.push({ key: 'U', action: 'Restore' })
|
||||
} else {
|
||||
base.push({ key: 'X', action: 'Discard' })
|
||||
}
|
||||
base.push(
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
{ key: 'Esc', action: 'Deselect' }
|
||||
)
|
||||
return base
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: '↑↓←→', action: 'Navigate' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Tab', action: 'Library panel' },
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
]
|
||||
}
|
||||
|
||||
export function KeyboardHints() {
|
||||
const selectedCount = usePhotoStore((s) => s.selectedPhotos.length)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
|
||||
)
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
// `H` toggles the panel.
|
||||
useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
|
||||
|
||||
const hints = getHints({ selectedCount, currentSection, viewMode })
|
||||
|
||||
// Nothing relevant to show — hide entirely.
|
||||
if (hints.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute bottom-0 left-1/2 z-30 -translate-x-1/2 pb-4">
|
||||
{collapsed ? (
|
||||
// Collapsed handle: a small pill peeking from the bottom so the
|
||||
// user can re-open the panel without remembering the shortcut.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-white/15 bg-black/80 px-3 py-1 text-[11px] text-white/80 shadow-xl backdrop-blur-md transition-colors hover:bg-black/90 hover:text-white"
|
||||
title="Show shortcuts (H)"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
Shortcuts
|
||||
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||
H
|
||||
</kbd>
|
||||
</button>
|
||||
) : (
|
||||
<div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md">
|
||||
{hints.map((hint, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
|
||||
{hint.key}
|
||||
</kbd>
|
||||
<span className="whitespace-nowrap text-xs text-white/85">
|
||||
{hint.action}
|
||||
</span>
|
||||
<span className="ml-1 text-white/30">•</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="-mr-1 flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
title="Hide shortcuts (H)"
|
||||
>
|
||||
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||
H
|
||||
</kbd>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { library, WorkerStatus } from '../services/api'
|
||||
|
||||
interface ScanStatus {
|
||||
is_scanning: boolean
|
||||
current_folder?: string
|
||||
processed_files: number
|
||||
total_files: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'scanning' | 'processing' | 'done'
|
||||
|
||||
/**
|
||||
* Headless background-activity orchestrator. Polls scan + worker status
|
||||
* and invalidates affected query caches when a scan/processing pass
|
||||
* completes. The visible status indicator now lives inline in the
|
||||
* LeftSidebar (small spinner next to the FOLDERS header / specific
|
||||
* folder rows) — see useScanActivity.
|
||||
*/
|
||||
export function ScanProgress() {
|
||||
const queryClient = useQueryClient()
|
||||
const wasScanningRef = useRef(false)
|
||||
const wasProcessingRef = useRef(false)
|
||||
|
||||
const { data: scanStatus } = useQuery<ScanStatus>({
|
||||
queryKey: ['scan-status'],
|
||||
queryFn: () => library.scanStatus(),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.is_scanning ? 2000 : 30000,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const isScanning = scanStatus?.is_scanning ?? false
|
||||
|
||||
// Poll worker status to track vision queue activity.
|
||||
// Fast polling (3s) while processing, slow (30s) otherwise.
|
||||
const { data: workerStatus } = useQuery<WorkerStatus>({
|
||||
queryKey: ['worker-status-progress'],
|
||||
queryFn: () => library.maintenance.workerStatus(),
|
||||
refetchInterval: (query) => {
|
||||
const q = totalQueued(query.state.data)
|
||||
return q > 0 ? 3000 : 30000
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const totalActive = totalQueued(workerStatus)
|
||||
|
||||
const phase: Phase = isScanning
|
||||
? 'scanning'
|
||||
: totalActive > 0
|
||||
? 'processing'
|
||||
: 'idle'
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'scanning') {
|
||||
wasScanningRef.current = true
|
||||
wasProcessingRef.current = false
|
||||
} else if (phase === 'processing') {
|
||||
wasProcessingRef.current = true
|
||||
if (wasScanningRef.current) {
|
||||
wasScanningRef.current = false
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||
}
|
||||
} else if (phase === 'idle') {
|
||||
if (wasScanningRef.current) {
|
||||
wasScanningRef.current = false
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||
}
|
||||
if (wasProcessingRef.current) {
|
||||
wasProcessingRef.current = false
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||
}
|
||||
}
|
||||
}, [phase, queryClient])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function totalQueued(ws: WorkerStatus | undefined): number {
|
||||
if (!ws) return 0
|
||||
const queued = Object.values(ws.queues ?? {}).reduce((a, b) => a + b, 0)
|
||||
const active = ws.workers?.reduce(
|
||||
(sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0
|
||||
) ?? 0
|
||||
return queued + active
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { toast as sonnerToast } from 'sonner'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** Shim that preserves the legacy `(title, message?, action?)` call
|
||||
* shape used throughout the codebase while delegating to sonner for
|
||||
* actual rendering. Call sites don't need to change. Actions auto-
|
||||
* extend the toast duration to 8s so users have time to hit Undo. */
|
||||
const build = (message?: string, action?: ToastAction, duration = 5000) => ({
|
||||
description: message,
|
||||
action: action && { label: action.label, onClick: action.onClick },
|
||||
duration: action ? Math.max(duration, 8000) : duration,
|
||||
})
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, message?: string, action?: ToastAction) =>
|
||||
sonnerToast.success(title, build(message, action)),
|
||||
error: (title: string, message?: string, action?: ToastAction) =>
|
||||
sonnerToast.error(title, build(message, action)),
|
||||
info: (title: string, message?: string, action?: ToastAction) =>
|
||||
sonnerToast.info(title, build(message, action)),
|
||||
warning: (title: string, message?: string, action?: ToastAction) =>
|
||||
sonnerToast.warning(title, build(message, action)),
|
||||
}
|
||||
|
||||
/** Mounted once near the App root. Delegates to sonner's `<Toaster />`
|
||||
* with palette-matched class overrides (see `@/components/ui/sonner`). */
|
||||
export function ToastContainer() {
|
||||
return <Toaster />
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react'
|
||||
import { admin, type AdminUser } from '../../services/api'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||
|
||||
export function UserManagement() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
|
||||
const [deactivatingUser, setDeactivatingUser] = useState<AdminUser | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await admin.listUsers()
|
||||
setUsers(data.users)
|
||||
} catch {
|
||||
setError('Failed to load users.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivatingUser) return
|
||||
try {
|
||||
await admin.deleteUser(deactivatingUser.id)
|
||||
setDeactivatingUser(null)
|
||||
fetchUsers()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
|
||||
setDeactivatingUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-sm text-text-muted">Loading users…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text">Users</h3>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-text-muted">
|
||||
<th className="pb-1 pr-4">Username</th>
|
||||
<th className="pb-1 pr-4">Role</th>
|
||||
<th className="pb-1 pr-4">Photos</th>
|
||||
<th className="pb-1 pr-4">Status</th>
|
||||
<th className="pb-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-border/50">
|
||||
<td className="py-1.5 pr-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{u.role === 'admin' ? (
|
||||
<Shield className="h-3 w-3 text-accent" />
|
||||
) : (
|
||||
<UserIcon className="h-3 w-3 text-text-muted" />
|
||||
)}
|
||||
<span className="text-text">{u.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-text-muted">{u.role}</td>
|
||||
<td className="py-1.5 pr-4 text-text-muted">
|
||||
{u.photo_count.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
<span
|
||||
className={u.is_active ? 'text-pick' : 'text-reject'}
|
||||
>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingUser(u)}
|
||||
title="Edit user"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
{u.is_active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-reject"
|
||||
onClick={() => setDeactivatingUser(u)}
|
||||
title="Deactivate user"
|
||||
>
|
||||
<UserX className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<CreateUserModal
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={() => {
|
||||
setShowCreate(false)
|
||||
fetchUsers()
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSaved={() => {
|
||||
setEditingUser(null)
|
||||
fetchUsers()
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deactivatingUser}
|
||||
title={`Deactivate "${deactivatingUser?.username}"?`}
|
||||
message="Their photos will be preserved."
|
||||
confirmLabel="Deactivate"
|
||||
destructive
|
||||
onConfirm={handleDeactivate}
|
||||
onClose={() => setDeactivatingUser(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create User Modal ──────────────────────────────────────────────────
|
||||
|
||||
function CreateUserModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [role, setRole] = useState<'user' | 'admin'>('user')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setRole('user')
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
await admin.createUser({ username: username.trim(), password, role })
|
||||
onCreated()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail ?? 'Failed to create user.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-username">Username</Label>
|
||||
<Input
|
||||
id="new-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-password">Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v as 'user' | 'admin')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? 'Creating\u2026' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Edit User Modal ────────────────────────────────────────────────────
|
||||
|
||||
function EditUserModal({
|
||||
user,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
user: AdminUser | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [role, setRole] = useState<'user' | 'admin'>('user')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setRole(user.role as 'user' | 'admin')
|
||||
setNewPassword('')
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user) return
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const data: { role?: string; new_password?: string } = {}
|
||||
if (role !== user.role) data.role = role
|
||||
if (newPassword) data.new_password = newPassword
|
||||
if (Object.keys(data).length > 0) {
|
||||
await admin.updateUser(user.id, data)
|
||||
}
|
||||
onSaved()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail ?? 'Failed to update user.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit: {user?.username}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v as 'user' | 'admin')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-password">
|
||||
New Password (leave blank to keep current)
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Unchanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? 'Saving\u2026' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import api from '../../services/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
interface OidcConfig {
|
||||
enabled: boolean
|
||||
label: string
|
||||
login_url: string
|
||||
}
|
||||
|
||||
interface AuthConfig {
|
||||
oidc: OidcConfig | null
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [oidc, setOidc] = useState<OidcConfig | null>(null)
|
||||
// While the OIDC config loads we may auto-bounce to the IdP. Hide
|
||||
// the form until we know we're staying so the user doesn't see a
|
||||
// flash of password fields right before the redirect kicks in.
|
||||
const [autoRedirecting, setAutoRedirecting] = useState(true)
|
||||
|
||||
// Ask the backend which login methods to show. If OIDC is enabled
|
||||
// and the user already has an SSO session at the IdP, the natural
|
||||
// flow is for them to land here, get bounced through Authentik, and
|
||||
// come straight back signed in — without ever clicking a button.
|
||||
// Two escape hatches: `?password=1` in the URL for explicit password
|
||||
// login, and a `skipAutoSso` sessionStorage flag set by logout and
|
||||
// by the OIDC callback's error branch so users don't get trapped in
|
||||
// a redirect loop.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await api.get<AuthConfig>('/auth/config')
|
||||
if (cancelled) return
|
||||
const cfg = res.data.oidc
|
||||
setOidc(cfg)
|
||||
if (!cfg?.enabled) {
|
||||
setAutoRedirecting(false)
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (
|
||||
params.has('password') ||
|
||||
sessionStorage.getItem('skipAutoSso') === '1'
|
||||
) {
|
||||
sessionStorage.removeItem('skipAutoSso')
|
||||
setAutoRedirecting(false)
|
||||
return
|
||||
}
|
||||
window.location.href = cfg.login_url
|
||||
} catch {
|
||||
if (!cancelled) setAutoRedirecting(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.detail ?? 'Unable to sign in. Check your credentials.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (autoRedirecting) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-sm space-y-3 rounded-lg border border-border bg-surface p-8 text-center shadow-xl">
|
||||
<div className="text-text-muted">
|
||||
Signing in with {oidc?.label ?? 'identity provider'}…
|
||||
</div>
|
||||
<a
|
||||
href="?password=1"
|
||||
className="inline-block text-xs text-text-muted underline hover:text-text"
|
||||
onClick={() => setAutoRedirecting(false)}
|
||||
>
|
||||
Use password instead
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl">
|
||||
<h1 className="text-center text-xl font-semibold text-text">
|
||||
Sign in to Mulita
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{oidc?.enabled && (
|
||||
<>
|
||||
{/* Full-page navigation (not a fetch) — Authlib sets a
|
||||
* signed session cookie in the /oidc/login response, so
|
||||
* the browser needs to follow the redirect chain itself. */}
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={oidc.login_url}>Sign in with {oidc.label}</a>
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-text-muted">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
or continue with password
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-user">Username</Label>
|
||||
<Input
|
||||
id="login-user"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-pass">Password</Label>
|
||||
<Input
|
||||
id="login-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/** Landing page for the OIDC redirect.
|
||||
*
|
||||
* Authentik bounces the browser to /auth/callback?access_token=...&refresh_token=...
|
||||
* (or ?error=oidc_xxx when something went wrong). We pull those out of
|
||||
* the URL, hand them to AuthContext, then replace history so the
|
||||
* tokens don't linger in the location bar, the back button, or
|
||||
* whatever screen-recording the user has going.
|
||||
*
|
||||
* Tokens in the query string are an accepted trade-off here: they're
|
||||
* short-lived, they never leave the app origin, and the alternative
|
||||
* (HTTP-only cookies) would be a much larger rework of an otherwise
|
||||
* JWT-in-localStorage codebase.
|
||||
*/
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
oidc_exchange_failed: 'Could not complete sign-in with the identity provider.',
|
||||
oidc_missing_sub: 'Identity provider did not return a user identifier.',
|
||||
oidc_signup_disabled: 'Your identity provider account has not been authorized for this instance.',
|
||||
oidc_deactivated: 'This account has been deactivated.',
|
||||
}
|
||||
|
||||
export function OidcCallback() {
|
||||
const { onOidcTokens } = useAuth()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const errCode = params.get('error')
|
||||
|
||||
// Drop everything after the origin + root path, including the
|
||||
// tokens, so refreshing or sharing the URL doesn't leak them.
|
||||
const clean = () => window.history.replaceState({}, '', '/')
|
||||
|
||||
if (errCode) {
|
||||
// Don't auto-bounce back to Authentik on the next LoginPage
|
||||
// mount — show the error and let the user fall back to password
|
||||
// or retry deliberately.
|
||||
sessionStorage.setItem('skipAutoSso', '1')
|
||||
setError(ERROR_MESSAGES[errCode] || 'Sign-in failed. Please try again.')
|
||||
clean()
|
||||
return
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
sessionStorage.setItem('skipAutoSso', '1')
|
||||
setError('The identity provider did not return the expected tokens.')
|
||||
clean()
|
||||
return
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await onOidcTokens(accessToken, refreshToken)
|
||||
} catch {
|
||||
setError('Could not complete sign-in. Please try again.')
|
||||
} finally {
|
||||
clean()
|
||||
}
|
||||
})()
|
||||
}, [onOidcTokens])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-sm space-y-4 rounded-lg border border-border bg-surface p-8 shadow-xl">
|
||||
<h1 className="text-center text-lg font-semibold text-text">Sign-in failed</h1>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<Button className="w-full" onClick={() => (window.location.href = '/')}>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg">
|
||||
<div className="text-text-muted">Signing you in…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user