diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
new file mode 100644
index 0000000..4e3aa16
--- /dev/null
+++ b/.idea/sqldialects.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..2e3b17f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,266 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+An Actix-web REST API for serving images and videos from a filesystem with automatic thumbnail generation, EXIF extraction, tag organization, and a memories feature for browsing photos by date. Uses SQLite/Diesel ORM for data persistence and ffmpeg for video processing.
+
+## Development Commands
+
+### Building & Running
+```bash
+# Build for development
+cargo build
+
+# Build for release (uses thin LTO optimization)
+cargo build --release
+
+# Run the server (requires .env file with DATABASE_URL, BASE_PATH, THUMBNAILS, VIDEO_PATH, BIND_URL, SECRET_KEY)
+cargo run
+
+# Run with specific log level
+RUST_LOG=debug cargo run
+```
+
+### Testing
+```bash
+# Run all tests (requires BASE_PATH in .env)
+cargo test
+
+# Run specific test
+cargo test test_name
+
+# Run tests with output
+cargo test -- --nocapture
+```
+
+### Database Migrations
+```bash
+# Install diesel CLI (one-time setup)
+cargo install diesel_cli --no-default-features --features sqlite
+
+# Create new migration
+diesel migration generate migration_name
+
+# Run migrations (also runs automatically on app startup)
+diesel migration run
+
+# Revert last migration
+diesel migration revert
+
+# Regenerate schema.rs after manual migration changes
+diesel print-schema > src/database/schema.rs
+```
+
+### Code Quality
+```bash
+# Format code
+cargo fmt
+
+# Run clippy linter
+cargo clippy
+
+# Fix automatically fixable issues
+cargo fix
+```
+
+### Utility Binaries
+```bash
+# Two-phase cleanup: resolve missing files and validate file types
+cargo run --bin cleanup_files -- --base-path /path/to/media --database-url ./database.db
+
+# Batch extract EXIF for existing files
+cargo run --bin migrate_exif
+```
+
+## Architecture Overview
+
+### Core Components
+
+**Layered Architecture:**
+- **HTTP Layer** (`main.rs`): Route handlers for images, videos, metadata, tags, favorites, memories
+- **Auth Layer** (`auth.rs`): JWT token validation, Claims extraction via FromRequest trait
+- **Service Layer** (`files.rs`, `exif.rs`, `memories.rs`): Business logic for file operations and EXIF extraction
+- **DAO Layer** (`database/mod.rs`): Trait-based data access (ExifDao, UserDao, FavoriteDao, TagDao)
+- **Database Layer**: Diesel ORM with SQLite, schema in `database/schema.rs`
+
+**Async Actor System (Actix):**
+- `StreamActor`: Manages ffmpeg video processing lifecycle
+- `VideoPlaylistManager`: Scans directories and queues videos
+- `PlaylistGenerator`: Creates HLS playlists for video streaming
+
+### Database Schema & Patterns
+
+**Tables:**
+- `users`: Authentication (id, username, password_hash)
+- `favorites`: User-specific favorites (userid, path)
+- `tags`: Custom labels with timestamps
+- `tagged_photo`: Many-to-many photo-tag relationships
+- `image_exif`: Rich metadata (file_path + 16 EXIF fields: camera, GPS, dates, exposure settings)
+
+**DAO Pattern:**
+All database access goes through trait-based DAOs (e.g., `ExifDao`, `SqliteExifDao`). Connection pooling uses `Arc>`. All DB operations are traced with OpenTelemetry in release builds.
+
+**Key DAO Methods:**
+- `store_exif()`, `get_exif()`, `get_exif_batch()`: EXIF CRUD operations
+- `query_by_exif()`: Complex filtering by camera, GPS bounds, date ranges
+- Batch operations minimize DB hits during file watching
+
+### File Processing Pipeline
+
+**Thumbnail Generation:**
+1. Startup scan: Rayon parallel walk of BASE_PATH
+2. Creates 200x200 thumbnails in THUMBNAILS directory (mirrors source structure)
+3. Videos: extracts frame at 3-second mark via ffmpeg
+4. Images: uses `image` crate for JPEG/PNG processing
+
+**File Watching:**
+Runs in background thread with two-tier strategy:
+- **Quick scan** (default 60s): Recently modified files only
+- **Full scan** (default 3600s): Comprehensive directory check
+- Batch queries EXIF DB to detect new files
+- Configurable via `WATCH_QUICK_INTERVAL_SECONDS` and `WATCH_FULL_INTERVAL_SECONDS`
+
+**EXIF Extraction:**
+- Uses `kamadak-exif` crate
+- Supports: JPEG, TIFF, RAW (NEF, CR2, CR3), HEIF/HEIC, PNG, WebP
+- Extracts: camera make/model, lens, dimensions, GPS coordinates, focal length, aperture, shutter speed, ISO, date taken
+- Triggered on upload and during file watching
+
+**File Upload Behavior:**
+If file exists, appends timestamp to filename (`photo_1735124234.jpg`) to preserve history without overwrites.
+
+### Authentication Flow
+
+**Login:**
+1. POST `/login` with username/password
+2. Verify with `bcrypt::verify()` against password_hash
+3. Generate JWT with claims: `{ sub: user_id, exp: 5_days_from_now }`
+4. Sign with HS256 using `SECRET_KEY` environment variable
+
+**Authorization:**
+All protected endpoints extract `Claims` via `FromRequest` trait implementation. Token passed as `Authorization: Bearer ` header.
+
+### API Structure
+
+**Key Endpoint Patterns:**
+
+```rust
+// Image serving & upload
+GET /image?path=...&size=...&format=...
+POST /image (multipart file upload)
+
+// Metadata & EXIF
+GET /image/metadata?path=...
+
+// Advanced search with filters
+GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat=...&gps_lon=...&gps_radius_km=10&date_from=...&date_to=...&tag_ids=1,2,3&media_type=Photo
+
+// Video streaming (HLS)
+POST /video/generate (creates .m3u8 playlist + .ts segments)
+GET /video/stream?path=... (serves playlist)
+
+// Tags
+GET /image/tags/all
+POST /image/tags (add tag to file)
+DELETE /image/tags (remove tag from file)
+POST /image/tags/batch (bulk tag updates)
+
+// Memories (week-based grouping)
+GET /memories?path=...&recursive=true
+```
+
+**Request Types:**
+- `FilesRequest`: Supports complex filtering (tags, EXIF fields, GPS radius, date ranges)
+- `SortType`: Shuffle, NameAsc/Desc, TagCountAsc/Desc, DateTakenAsc/Desc
+
+### Important Patterns
+
+**Service Builder Pattern:**
+Routes are registered via composable `ServiceBuilder` trait in `service.rs`. Allows modular feature addition.
+
+**Path Validation:**
+Always use `is_valid_full_path(&base_path, &requested_path, check_exists)` to prevent directory traversal attacks.
+
+**File Type Detection:**
+Centralized in `file_types.rs` with constants `IMAGE_EXTENSIONS` and `VIDEO_EXTENSIONS`. Provides both `Path` and `DirEntry` variants for performance.
+
+**OpenTelemetry Tracing:**
+All database operations and HTTP handlers wrapped in spans. In release builds, exports to OTLP endpoint via `OTLP_OTLS_ENDPOINT`. Debug builds use basic logger.
+
+**Memory Exclusion:**
+`PathExcluder` in `memories.rs` filters out directories from memories API via `EXCLUDED_DIRS` environment variable (comma-separated paths or substring patterns).
+
+### Startup Sequence
+
+1. Load `.env` file
+2. Run embedded Diesel migrations
+3. Spawn file watcher thread
+4. Create initial thumbnails (parallel scan)
+5. Generate video GIF thumbnails
+6. Initialize AppState with Actix actors
+7. Set up Prometheus metrics (`imageserver_image_total`, `imageserver_video_total`)
+8. Scan directory for videos and queue HLS processing
+9. Start HTTP server on `BIND_URL` + localhost:8088
+
+## Testing Patterns
+
+Tests require `BASE_PATH` environment variable. Many integration tests create temporary directories and files.
+
+When testing database code:
+- Use in-memory SQLite: `DATABASE_URL=":memory:"`
+- Run migrations in test setup
+- Clean up with `DROP TABLE` or use `#[serial]` from `serial_test` crate if parallel tests conflict
+
+## Common Gotchas
+
+**EXIF Date Parsing:**
+Multiple formats supported (EXIF DateTime, ISO8601, Unix timestamp). Fallback chain attempts multiple parsers.
+
+**Video Processing:**
+ffmpeg processes run asynchronously via actors. Use `StreamActor` to track completion. HLS segments written to `VIDEO_PATH`.
+
+**File Extensions:**
+Extension detection is case-insensitive. Use `file_types.rs` helpers rather than manual string matching.
+
+**Migration Workflow:**
+After creating a migration, manually edit the SQL, then regenerate `schema.rs` with `diesel print-schema`. Migrations auto-run on startup via `embedded_migrations!()` macro.
+
+**Path Absolutization:**
+Use `path-absolutize` crate's `.absolutize()` method when converting user-provided paths to ensure they're within `BASE_PATH`.
+
+## Required Environment Variables
+
+```bash
+DATABASE_URL=./database.db # SQLite database path
+BASE_PATH=/path/to/media # Root media directory
+THUMBNAILS=/path/to/thumbnails # Thumbnail storage
+VIDEO_PATH=/path/to/video/hls # HLS playlist output
+GIFS_DIRECTORY=/path/to/gifs # Video GIF thumbnails
+BIND_URL=0.0.0.0:8080 # Server binding
+CORS_ALLOWED_ORIGINS=http://localhost:3000
+SECRET_KEY=your-secret-key-here # JWT signing secret
+RUST_LOG=info # Log level
+EXCLUDED_DIRS=/private,/archive # Comma-separated paths to exclude from memories
+```
+
+Optional:
+```bash
+WATCH_QUICK_INTERVAL_SECONDS=60 # Quick scan interval
+WATCH_FULL_INTERVAL_SECONDS=3600 # Full scan interval
+OTLP_OTLS_ENDPOINT=http://... # OpenTelemetry collector (release builds)
+```
+
+## Dependencies of Note
+
+- **actix-web**: HTTP framework
+- **diesel**: ORM for SQLite
+- **jsonwebtoken**: JWT implementation
+- **kamadak-exif**: EXIF parsing
+- **image**: Thumbnail generation
+- **walkdir**: Directory traversal
+- **rayon**: Parallel processing
+- **opentelemetry**: Distributed tracing
+- **bcrypt**: Password hashing
+- **infer**: Magic number file type detection
diff --git a/Cargo.lock b/Cargo.lock
index 5accb47..c390518 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,7 +11,7 @@ dependencies = [
"actix-macros",
"actix-rt",
"actix_derive",
- "bitflags 2.9.3",
+ "bitflags",
"bytes",
"crossbeam-channel",
"futures-core",
@@ -33,7 +33,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
dependencies = [
- "bitflags 2.9.3",
+ "bitflags",
"bytes",
"futures-core",
"futures-sink",
@@ -44,6 +44,21 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "actix-cors"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
+dependencies = [
+ "actix-utils",
+ "actix-web",
+ "derive_more 2.0.1",
+ "futures-util",
+ "log",
+ "once_cell",
+ "smallvec",
+]
+
[[package]]
name = "actix-files"
version = "0.6.7"
@@ -54,7 +69,7 @@ dependencies = [
"actix-service",
"actix-utils",
"actix-web",
- "bitflags 2.9.3",
+ "bitflags",
"bytes",
"derive_more 2.0.1",
"futures-core",
@@ -78,7 +93,7 @@ dependencies = [
"actix-service",
"actix-utils",
"base64",
- "bitflags 2.9.3",
+ "bitflags",
"brotli",
"bytes",
"bytestring",
@@ -191,7 +206,7 @@ dependencies = [
"actix-utils",
"futures-core",
"futures-util",
- "mio 1.0.4",
+ "mio",
"socket2 0.5.10",
"tokio",
"tracing",
@@ -509,23 +524,17 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
-version = "0.16.0"
+version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e"
+checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c"
dependencies = [
"base64",
"blowfish",
- "getrandom 0.2.16",
+ "getrandom 0.3.3",
"subtle",
"zeroize",
]
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
[[package]]
name = "bitflags"
version = "2.9.3"
@@ -635,6 +644,17 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
[[package]]
name = "cfg-expr"
version = "0.15.8"
@@ -675,12 +695,65 @@ dependencies = [
"inout",
]
+[[package]]
+name = "clap"
+version = "4.5.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "console"
+version = "0.15.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "once_cell",
+ "unicode-width",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -844,6 +917,19 @@ dependencies = [
"unicode-xid",
]
+[[package]]
+name = "dialoguer"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
+dependencies = [
+ "console",
+ "shell-words",
+ "tempfile",
+ "thiserror 1.0.69",
+ "zeroize",
+]
+
[[package]]
name = "diesel"
version = "2.2.12"
@@ -935,6 +1021,12 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1018,18 +1110,6 @@ dependencies = [
"simd-adler32",
]
-[[package]]
-name = "filetime"
-version = "0.2.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
-dependencies = [
- "cfg-if",
- "libc",
- "libredox",
- "windows-sys 0.60.2",
-]
-
[[package]]
name = "find-msvc-tools"
version = "0.1.0"
@@ -1067,15 +1147,6 @@ dependencies = [
"percent-encoding",
]
-[[package]]
-name = "fsevent-sys"
-version = "4.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "futures"
version = "0.3.31"
@@ -1534,9 +1605,10 @@ dependencies = [
[[package]]
name = "image-api"
-version = "0.3.1"
+version = "0.4.0"
dependencies = [
"actix",
+ "actix-cors",
"actix-files",
"actix-multipart",
"actix-rt",
@@ -1545,16 +1617,19 @@ dependencies = [
"anyhow",
"bcrypt",
"chrono",
+ "clap",
+ "dialoguer",
"diesel",
"diesel_migrations",
"dotenv",
"env_logger",
"futures",
"image",
+ "infer",
"jsonwebtoken",
+ "kamadak-exif",
"lazy_static",
"log",
- "notify",
"opentelemetry",
"opentelemetry-appender-log",
"opentelemetry-otlp",
@@ -1595,23 +1670,12 @@ dependencies = [
]
[[package]]
-name = "inotify"
-version = "0.9.6"
+name = "infer"
+version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
+checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847"
dependencies = [
- "bitflags 1.3.2",
- "inotify-sys",
- "libc",
-]
-
-[[package]]
-name = "inotify-sys"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
-dependencies = [
- "libc",
+ "cfb",
]
[[package]]
@@ -1640,7 +1704,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
- "bitflags 2.9.3",
+ "bitflags",
"cfg-if",
"libc",
]
@@ -1751,23 +1815,12 @@ dependencies = [
]
[[package]]
-name = "kqueue"
-version = "1.1.1"
+name = "kamadak-exif"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
+checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
dependencies = [
- "kqueue-sys",
- "libc",
-]
-
-[[package]]
-name = "kqueue-sys"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
-dependencies = [
- "bitflags 1.3.2",
- "libc",
+ "mutate_once",
]
[[package]]
@@ -1798,17 +1851,6 @@ dependencies = [
"cc",
]
-[[package]]
-name = "libredox"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
-dependencies = [
- "bitflags 2.9.3",
- "libc",
- "redox_syscall",
-]
-
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
@@ -1942,18 +1984,6 @@ dependencies = [
"simd-adler32",
]
-[[package]]
-name = "mio"
-version = "0.8.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
-dependencies = [
- "libc",
- "log",
- "wasi 0.11.1+wasi-snapshot-preview1",
- "windows-sys 0.48.0",
-]
-
[[package]]
name = "mio"
version = "1.0.4"
@@ -1976,6 +2006,12 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "mutate_once"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
+
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -1998,25 +2034,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
-[[package]]
-name = "notify"
-version = "6.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
-dependencies = [
- "bitflags 2.9.3",
- "crossbeam-channel",
- "filetime",
- "fsevent-sys",
- "inotify",
- "kqueue",
- "libc",
- "log",
- "mio 0.8.11",
- "walkdir",
- "windows-sys 0.48.0",
-]
-
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -2096,9 +2113,9 @@ checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "opentelemetry"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6"
+checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
dependencies = [
"futures-core",
"futures-sink",
@@ -2110,9 +2127,9 @@ dependencies = [
[[package]]
name = "opentelemetry-appender-log"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e688026e48f4603494f619583e0aa0b0edd9c0b9430e1c46804df2ff32bc8798"
+checksum = "9e50c59a96bd6a723a4329c5db31eb04fa4488c5f141ae7b9d4fd587439e6ee1"
dependencies = [
"log",
"opentelemetry",
@@ -2120,9 +2137,9 @@ dependencies = [
[[package]]
name = "opentelemetry-http"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d"
+checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
dependencies = [
"async-trait",
"bytes",
@@ -2133,9 +2150,9 @@ dependencies = [
[[package]]
name = "opentelemetry-otlp"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b"
+checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf"
dependencies = [
"http 1.3.1",
"opentelemetry",
@@ -2152,21 +2169,22 @@ dependencies = [
[[package]]
name = "opentelemetry-proto"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc"
+checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
"tonic",
+ "tonic-prost",
]
[[package]]
name = "opentelemetry-stdout"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "447191061af41c3943e082ea359ab8b64ff27d6d34d30d327df309ddef1eef6f"
+checksum = "bc8887887e169414f637b18751487cce4e095be787d23fad13c454e2fb1b3811"
dependencies = [
"chrono",
"opentelemetry",
@@ -2175,9 +2193,9 @@ dependencies = [
[[package]]
name = "opentelemetry_sdk"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b"
+checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
dependencies = [
"futures-channel",
"futures-executor",
@@ -2185,7 +2203,6 @@ dependencies = [
"opentelemetry",
"percent-encoding",
"rand 0.9.2",
- "serde_json",
"thiserror 2.0.16",
"tokio",
"tokio-stream",
@@ -2304,7 +2321,7 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
- "bitflags 2.9.3",
+ "bitflags",
"crc32fast",
"fdeflate",
"flate2",
@@ -2395,9 +2412,9 @@ dependencies = [
[[package]]
name = "prost"
-version = "0.13.5"
+version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
dependencies = [
"bytes",
"prost-derive",
@@ -2405,9 +2422,9 @@ dependencies = [
[[package]]
name = "prost-derive"
-version = "0.13.5"
+version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -2587,7 +2604,7 @@ version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
dependencies = [
- "bitflags 2.9.3",
+ "bitflags",
]
[[package]]
@@ -2700,7 +2717,7 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
- "bitflags 2.9.3",
+ "bitflags",
"errno",
"libc",
"linux-raw-sys",
@@ -2822,6 +2839,12 @@ dependencies = [
"digest",
]
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -3073,7 +3096,7 @@ dependencies = [
"bytes",
"io-uring",
"libc",
- "mio 1.0.4",
+ "mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -3181,9 +3204,9 @@ checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
[[package]]
name = "tonic"
-version = "0.13.1"
+version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9"
+checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
dependencies = [
"async-trait",
"base64",
@@ -3196,7 +3219,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
- "prost",
+ "sync_wrapper",
"tokio",
"tokio-stream",
"tower",
@@ -3205,6 +3228,17 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "tonic-prost"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67"
+dependencies = [
+ "bytes",
+ "prost",
+ "tonic",
+]
+
[[package]]
name = "tower"
version = "0.5.2"
@@ -3230,7 +3264,7 @@ version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
- "bitflags 2.9.3",
+ "bitflags",
"bytes",
"futures-util",
"http 1.3.1",
@@ -3310,6 +3344,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -3346,6 +3386,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "uuid"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "v_frame"
version = "0.3.9"
@@ -3564,15 +3614,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "windows-sys"
-version = "0.48.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
-dependencies = [
- "windows-targets 0.48.5",
-]
-
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -3600,21 +3641,6 @@ dependencies = [
"windows-targets 0.53.3",
]
-[[package]]
-name = "windows-targets"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
-dependencies = [
- "windows_aarch64_gnullvm 0.48.5",
- "windows_aarch64_msvc 0.48.5",
- "windows_i686_gnu 0.48.5",
- "windows_i686_msvc 0.48.5",
- "windows_x86_64_gnu 0.48.5",
- "windows_x86_64_gnullvm 0.48.5",
- "windows_x86_64_msvc 0.48.5",
-]
-
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -3648,12 +3674,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
-
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -3666,12 +3686,6 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -3684,12 +3698,6 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -3714,12 +3722,6 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -3732,12 +3734,6 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -3750,12 +3746,6 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -3768,12 +3758,6 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
diff --git a/Cargo.toml b/Cargo.toml
index 736dfa5..481c713 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,13 +1,13 @@
[package]
name = "image-api"
-version = "0.3.1"
+version = "0.4.0"
authors = ["Cameron Cordes "]
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[profile.release]
-lto = true
+lto = "thin"
[dependencies]
actix = "0.13.1"
@@ -15,6 +15,7 @@ actix-web = "4"
actix-rt = "2.6"
tokio = { version = "1.42.0", features = ["default", "process", "sync"] }
actix-files = "0.6"
+actix-cors = "0.7"
actix-multipart = "0.7.2"
futures = "0.3.5"
jsonwebtoken = "9.3.0"
@@ -23,12 +24,14 @@ serde_json = "1"
diesel = { version = "2.2.10", features = ["sqlite"] }
diesel_migrations = "2.2.0"
chrono = "0.4"
+clap = { version = "4.5", features = ["derive"] }
+dialoguer = "0.11"
dotenv = "0.15"
-bcrypt = "0.16.0"
+bcrypt = "0.17.1"
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "rayon"] }
+infer = "0.16"
walkdir = "2.4.0"
rayon = "1.5"
-notify = "6.1.1"
path-absolutize = "3.1"
log = "0.4"
env_logger = "0.11.5"
@@ -37,10 +40,11 @@ prometheus = "0.13"
lazy_static = "1.5"
anyhow = "1.0"
rand = "0.8.5"
-opentelemetry = { version = "0.30.0", features = ["default", "metrics", "tracing"] }
-opentelemetry_sdk = { version = "0.30.0", features = ["default", "rt-tokio-current-thread", "metrics"] }
-opentelemetry-otlp = { version = "0.30.0", features = ["default", "metrics", "tracing", "grpc-tonic"] }
-opentelemetry-stdout = "0.30.0"
-opentelemetry-appender-log = "0.30.0"
+opentelemetry = { version = "0.31.0", features = ["default", "metrics", "tracing"] }
+opentelemetry_sdk = { version = "0.31.0", features = ["default", "rt-tokio-current-thread", "metrics"] }
+opentelemetry-otlp = { version = "0.31.0", features = ["default", "metrics", "tracing", "grpc-tonic"] }
+opentelemetry-stdout = "0.31.0"
+opentelemetry-appender-log = "0.31.0"
tempfile = "3.20.0"
-regex = "1.11.1"
\ No newline at end of file
+regex = "1.11.1"
+exif = { package = "kamadak-exif", version = "0.6.1" }
\ No newline at end of file
diff --git a/README.md b/README.md
index e03657f..e340dc1 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,14 @@
This is an Actix-web server for serving images and videos from a filesystem.
Upon first run it will generate thumbnails for all images and videos at `BASE_PATH`.
+## Features
+- Automatic thumbnail generation for images and videos
+- EXIF data extraction and storage for photos
+- File watching with NFS support (polling-based)
+- Video streaming with HLS
+- Tag-based organization
+- Memories API for browsing photos by date
+
## Environment
There are a handful of required environment variables to have the API run.
They should be defined where the binary is located or above it in an `.env` file.
@@ -15,3 +23,6 @@ You must have `ffmpeg` installed for streaming video and generating video thumbn
- `SECRET_KEY` is the *hopefully* random string to sign Tokens with
- `RUST_LOG` is one of `off, error, warn, info, debug, trace`, from least to most noisy [error is default]
- `EXCLUDED_DIRS` is a comma separated list of directories to exclude from the Memories API
+- `WATCH_QUICK_INTERVAL_SECONDS` (optional) is the interval in seconds for quick file scans [default: 60]
+- `WATCH_FULL_INTERVAL_SECONDS` (optional) is the interval in seconds for full file scans [default: 3600]
+
diff --git a/migrations/2025-12-17-000000_create_image_exif/down.sql b/migrations/2025-12-17-000000_create_image_exif/down.sql
new file mode 100644
index 0000000..9baca92
--- /dev/null
+++ b/migrations/2025-12-17-000000_create_image_exif/down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_image_exif_file_path;
+DROP TABLE IF EXISTS image_exif;
diff --git a/migrations/2025-12-17-000000_create_image_exif/up.sql b/migrations/2025-12-17-000000_create_image_exif/up.sql
new file mode 100644
index 0000000..8041d06
--- /dev/null
+++ b/migrations/2025-12-17-000000_create_image_exif/up.sql
@@ -0,0 +1,32 @@
+CREATE TABLE image_exif (
+ id INTEGER PRIMARY KEY NOT NULL,
+ file_path TEXT NOT NULL UNIQUE,
+
+ -- Camera Information
+ camera_make TEXT,
+ camera_model TEXT,
+ lens_model TEXT,
+
+ -- Image Properties
+ width INTEGER,
+ height INTEGER,
+ orientation INTEGER,
+
+ -- GPS Coordinates
+ gps_latitude REAL,
+ gps_longitude REAL,
+ gps_altitude REAL,
+
+ -- Capture Settings
+ focal_length REAL,
+ aperture REAL,
+ shutter_speed TEXT,
+ iso INTEGER,
+ date_taken BIGINT,
+
+ -- Housekeeping
+ created_time BIGINT NOT NULL,
+ last_modified BIGINT NOT NULL
+);
+
+CREATE INDEX idx_image_exif_file_path ON image_exif(file_path);
diff --git a/migrations/2025-12-17-230000_add_indexes/down.sql b/migrations/2025-12-17-230000_add_indexes/down.sql
new file mode 100644
index 0000000..3025574
--- /dev/null
+++ b/migrations/2025-12-17-230000_add_indexes/down.sql
@@ -0,0 +1,9 @@
+-- Rollback indexes
+
+DROP INDEX IF EXISTS idx_favorites_userid;
+DROP INDEX IF EXISTS idx_favorites_path;
+DROP INDEX IF EXISTS idx_tags_name;
+DROP INDEX IF EXISTS idx_tagged_photo_photo_name;
+DROP INDEX IF EXISTS idx_tagged_photo_tag_id;
+DROP INDEX IF EXISTS idx_image_exif_camera;
+DROP INDEX IF EXISTS idx_image_exif_gps;
diff --git a/migrations/2025-12-17-230000_add_indexes/up.sql b/migrations/2025-12-17-230000_add_indexes/up.sql
new file mode 100644
index 0000000..276c70b
--- /dev/null
+++ b/migrations/2025-12-17-230000_add_indexes/up.sql
@@ -0,0 +1,17 @@
+-- Add indexes for improved query performance
+
+-- Favorites table indexes
+CREATE INDEX IF NOT EXISTS idx_favorites_userid ON favorites(userid);
+CREATE INDEX IF NOT EXISTS idx_favorites_path ON favorites(path);
+
+-- Tags table indexes
+CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
+
+-- Tagged photos indexes
+CREATE INDEX IF NOT EXISTS idx_tagged_photo_photo_name ON tagged_photo(photo_name);
+CREATE INDEX IF NOT EXISTS idx_tagged_photo_tag_id ON tagged_photo(tag_id);
+
+-- EXIF table indexes (date_taken already has index from previous migration)
+-- Adding composite index for common EXIF queries
+CREATE INDEX IF NOT EXISTS idx_image_exif_camera ON image_exif(camera_make, camera_model);
+CREATE INDEX IF NOT EXISTS idx_image_exif_gps ON image_exif(gps_latitude, gps_longitude);
diff --git a/migrations/2025-12-17-230100_unique_favorites/down.sql b/migrations/2025-12-17-230100_unique_favorites/down.sql
new file mode 100644
index 0000000..b42819b
--- /dev/null
+++ b/migrations/2025-12-17-230100_unique_favorites/down.sql
@@ -0,0 +1,3 @@
+-- Rollback unique constraint on favorites
+
+DROP INDEX IF EXISTS idx_favorites_unique;
diff --git a/migrations/2025-12-17-230100_unique_favorites/up.sql b/migrations/2025-12-17-230100_unique_favorites/up.sql
new file mode 100644
index 0000000..b123617
--- /dev/null
+++ b/migrations/2025-12-17-230100_unique_favorites/up.sql
@@ -0,0 +1,12 @@
+-- Add unique constraint to prevent duplicate favorites per user
+
+-- First, remove any existing duplicates (keep the oldest one)
+DELETE FROM favorites
+WHERE rowid NOT IN (
+ SELECT MIN(rowid)
+ FROM favorites
+ GROUP BY userid, path
+);
+
+-- Add unique index to enforce constraint
+CREATE UNIQUE INDEX idx_favorites_unique ON favorites(userid, path);
diff --git a/migrations/2025-12-18-120000_add_date_taken_index/down.sql b/migrations/2025-12-18-120000_add_date_taken_index/down.sql
new file mode 100644
index 0000000..4b0b0f6
--- /dev/null
+++ b/migrations/2025-12-18-120000_add_date_taken_index/down.sql
@@ -0,0 +1,2 @@
+-- Remove date_taken index
+DROP INDEX IF EXISTS idx_image_exif_date_taken;
diff --git a/migrations/2025-12-18-120000_add_date_taken_index/up.sql b/migrations/2025-12-18-120000_add_date_taken_index/up.sql
new file mode 100644
index 0000000..d29931a
--- /dev/null
+++ b/migrations/2025-12-18-120000_add_date_taken_index/up.sql
@@ -0,0 +1,2 @@
+-- Add index on date_taken for efficient date range queries
+CREATE INDEX IF NOT EXISTS idx_image_exif_date_taken ON image_exif(date_taken);
diff --git a/src/auth.rs b/src/auth.rs
index 9012e4f..9ee09bf 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -64,7 +64,6 @@ pub async fn login(
#[cfg(test)]
mod tests {
-
use super::*;
use crate::testhelpers::{BodyReader, TestUserDao};
diff --git a/src/bin/cleanup_files.rs b/src/bin/cleanup_files.rs
new file mode 100644
index 0000000..7211d5e
--- /dev/null
+++ b/src/bin/cleanup_files.rs
@@ -0,0 +1,143 @@
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+use clap::Parser;
+
+use image_api::cleanup::{
+ CleanupConfig, DatabaseUpdater, resolve_missing_files, validate_file_types,
+};
+use image_api::database::{SqliteExifDao, SqliteFavoriteDao};
+use image_api::tags::SqliteTagDao;
+
+#[derive(Parser, Debug)]
+#[command(name = "cleanup_files")]
+#[command(about = "File cleanup and fix utility for ImageApi", long_about = None)]
+struct Args {
+ #[arg(long, help = "Preview changes without making them")]
+ dry_run: bool,
+
+ #[arg(long, help = "Auto-fix all issues without prompting")]
+ auto_fix: bool,
+
+ #[arg(long, help = "Skip phase 1 (missing file resolution)")]
+ skip_phase1: bool,
+
+ #[arg(long, help = "Skip phase 2 (file type validation)")]
+ skip_phase2: bool,
+}
+
+fn main() -> anyhow::Result<()> {
+ // Initialize logging
+ env_logger::init();
+
+ // Load environment variables
+ dotenv::dotenv()?;
+
+ // Parse CLI arguments
+ let args = Args::parse();
+
+ // Get base path from environment
+ let base_path = dotenv::var("BASE_PATH")?;
+ let base = PathBuf::from(&base_path);
+
+ println!("File Cleanup and Fix Utility");
+ println!("============================");
+ println!("Base path: {}", base.display());
+ println!("Dry run: {}", args.dry_run);
+ println!("Auto fix: {}", args.auto_fix);
+ println!();
+
+ // Pre-flight checks
+ if !base.exists() {
+ eprintln!("Error: Base path does not exist: {}", base.display());
+ std::process::exit(1);
+ }
+
+ if !base.is_dir() {
+ eprintln!("Error: Base path is not a directory: {}", base.display());
+ std::process::exit(1);
+ }
+
+ // Create configuration
+ let config = CleanupConfig {
+ base_path: base,
+ dry_run: args.dry_run,
+ auto_fix: args.auto_fix,
+ };
+
+ // Create DAOs
+ println!("Connecting to database...");
+ let tag_dao: Arc> =
+ Arc::new(Mutex::new(SqliteTagDao::default()));
+ let exif_dao: Arc> =
+ Arc::new(Mutex::new(SqliteExifDao::new()));
+ let favorites_dao: Arc> =
+ Arc::new(Mutex::new(SqliteFavoriteDao::new()));
+
+ // Create database updater
+ let mut db_updater = DatabaseUpdater::new(tag_dao, exif_dao, favorites_dao);
+
+ println!("✓ Database connected\n");
+
+ // Track overall statistics
+ let mut total_issues_found = 0;
+ let mut total_issues_fixed = 0;
+ let mut total_errors = Vec::new();
+
+ // Phase 1: Missing file resolution
+ if !args.skip_phase1 {
+ match resolve_missing_files(&config, &mut db_updater) {
+ Ok(stats) => {
+ total_issues_found += stats.issues_found;
+ total_issues_fixed += stats.issues_fixed;
+ total_errors.extend(stats.errors);
+ }
+ Err(e) => {
+ eprintln!("Phase 1 failed: {:?}", e);
+ total_errors.push(format!("Phase 1 error: {}", e));
+ }
+ }
+ } else {
+ println!("Phase 1: Skipped (--skip-phase1)");
+ }
+
+ // Phase 2: File type validation
+ if !args.skip_phase2 {
+ match validate_file_types(&config, &mut db_updater) {
+ Ok(stats) => {
+ total_issues_found += stats.issues_found;
+ total_issues_fixed += stats.issues_fixed;
+ total_errors.extend(stats.errors);
+ }
+ Err(e) => {
+ eprintln!("Phase 2 failed: {:?}", e);
+ total_errors.push(format!("Phase 2 error: {}", e));
+ }
+ }
+ } else {
+ println!("\nPhase 2: Skipped (--skip-phase2)");
+ }
+
+ // Final summary
+ println!("\n============================");
+ println!("Cleanup Complete!");
+ println!("============================");
+ println!("Total issues found: {}", total_issues_found);
+ if config.dry_run {
+ println!("Total issues that would be fixed: {}", total_issues_found);
+ } else {
+ println!("Total issues fixed: {}", total_issues_fixed);
+ }
+
+ if !total_errors.is_empty() {
+ println!("\nErrors encountered:");
+ for (i, error) in total_errors.iter().enumerate() {
+ println!(" {}. {}", i + 1, error);
+ }
+ println!("\nSome operations failed. Review errors above.");
+ } else {
+ println!("\n✓ No errors encountered");
+ }
+
+ Ok(())
+}
diff --git a/src/bin/migrate_exif.rs b/src/bin/migrate_exif.rs
new file mode 100644
index 0000000..5f5af9d
--- /dev/null
+++ b/src/bin/migrate_exif.rs
@@ -0,0 +1,196 @@
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+use chrono::Utc;
+use clap::Parser;
+use opentelemetry;
+use rayon::prelude::*;
+use walkdir::WalkDir;
+
+use image_api::database::models::InsertImageExif;
+use image_api::database::{ExifDao, SqliteExifDao};
+use image_api::exif;
+
+#[derive(Parser, Debug)]
+#[command(name = "migrate_exif")]
+#[command(about = "Extract and store EXIF data from images", long_about = None)]
+struct Args {
+ #[arg(long, help = "Skip files that already have EXIF data in database")]
+ skip_existing: bool,
+}
+
+fn main() -> anyhow::Result<()> {
+ env_logger::init();
+ dotenv::dotenv()?;
+
+ let args = Args::parse();
+ let base_path = dotenv::var("BASE_PATH")?;
+ let base = PathBuf::from(&base_path);
+
+ println!("EXIF Migration Tool");
+ println!("===================");
+ println!("Base path: {}", base.display());
+ if args.skip_existing {
+ println!("Mode: Skip existing (incremental)");
+ } else {
+ println!("Mode: Upsert (insert new, update existing)");
+ }
+ println!();
+
+ // Collect all image files that support EXIF
+ println!("Scanning for images...");
+ let image_files: Vec = WalkDir::new(&base)
+ .into_iter()
+ .filter_map(|e| e.ok())
+ .filter(|e| e.file_type().is_file())
+ .filter(|e| exif::supports_exif(e.path()))
+ .map(|e| e.path().to_path_buf())
+ .collect();
+
+ println!("Found {} images to process", image_files.len());
+
+ if image_files.is_empty() {
+ println!("No EXIF-supporting images found. Exiting.");
+ return Ok(());
+ }
+
+ println!();
+ println!("Extracting EXIF data...");
+
+ // Create a thread-safe DAO
+ let dao = Arc::new(Mutex::new(SqliteExifDao::new()));
+
+ // Process in parallel using rayon
+ let results: Vec<_> = image_files
+ .par_iter()
+ .map(|path| {
+ // Create context for this processing iteration
+ let context = opentelemetry::Context::new();
+
+ let relative_path = match path.strip_prefix(&base) {
+ Ok(p) => p.to_str().unwrap().to_string(),
+ Err(_) => {
+ eprintln!(
+ "Error: Could not create relative path for {}",
+ path.display()
+ );
+ return Err(anyhow::anyhow!("Path error"));
+ }
+ };
+
+ // Check if EXIF data already exists
+ let existing = if let Ok(mut dao_lock) = dao.lock() {
+ dao_lock.get_exif(&context, &relative_path).ok().flatten()
+ } else {
+ eprintln!("✗ {} - Failed to acquire database lock", relative_path);
+ return Err(anyhow::anyhow!("Lock error"));
+ };
+
+ // Skip if exists and skip_existing flag is set
+ if args.skip_existing && existing.is_some() {
+ return Ok(("skip".to_string(), relative_path));
+ }
+
+ match exif::extract_exif_from_path(path) {
+ Ok(exif_data) => {
+ let timestamp = Utc::now().timestamp();
+ let insert_exif = InsertImageExif {
+ file_path: relative_path.clone(),
+ camera_make: exif_data.camera_make,
+ camera_model: exif_data.camera_model,
+ lens_model: exif_data.lens_model,
+ width: exif_data.width,
+ height: exif_data.height,
+ orientation: exif_data.orientation,
+ gps_latitude: exif_data.gps_latitude,
+ gps_longitude: exif_data.gps_longitude,
+ gps_altitude: exif_data.gps_altitude,
+ focal_length: exif_data.focal_length,
+ aperture: exif_data.aperture,
+ shutter_speed: exif_data.shutter_speed,
+ iso: exif_data.iso,
+ date_taken: exif_data.date_taken,
+ created_time: existing
+ .as_ref()
+ .map(|e| e.created_time)
+ .unwrap_or(timestamp),
+ last_modified: timestamp,
+ };
+
+ // Store or update in database
+ if let Ok(mut dao_lock) = dao.lock() {
+ let result = if existing.is_some() {
+ // Update existing record
+ dao_lock
+ .update_exif(&context, insert_exif)
+ .map(|_| "update")
+ } else {
+ // Insert new record
+ dao_lock.store_exif(&context, insert_exif).map(|_| "insert")
+ };
+
+ match result {
+ Ok(action) => {
+ if action == "update" {
+ println!("↻ {} (updated)", relative_path);
+ } else {
+ println!("✓ {} (inserted)", relative_path);
+ }
+ Ok((action.to_string(), relative_path))
+ }
+ Err(e) => {
+ eprintln!("✗ {} - Database error: {:?}", relative_path, e);
+ Err(anyhow::anyhow!("Database error"))
+ }
+ }
+ } else {
+ eprintln!("✗ {} - Failed to acquire database lock", relative_path);
+ Err(anyhow::anyhow!("Lock error"))
+ }
+ }
+ Err(e) => {
+ eprintln!("✗ {} - No EXIF data: {:?}", relative_path, e);
+ Err(e)
+ }
+ }
+ })
+ .collect();
+
+ // Count results
+ let mut success_count = 0;
+ let mut inserted_count = 0;
+ let mut updated_count = 0;
+ let mut skipped_count = 0;
+
+ for (action, _) in results.iter().flatten() {
+ success_count += 1;
+ match action.as_str() {
+ "insert" => inserted_count += 1,
+ "update" => updated_count += 1,
+ "skip" => skipped_count += 1,
+ _ => {}
+ }
+ }
+
+ let error_count = results.len() - success_count - skipped_count;
+
+ println!();
+ println!("===================");
+ println!("Migration complete!");
+ println!("Total images processed: {}", image_files.len());
+
+ if inserted_count > 0 {
+ println!(" New EXIF records inserted: {}", inserted_count);
+ }
+ if updated_count > 0 {
+ println!(" Existing records updated: {}", updated_count);
+ }
+ if skipped_count > 0 {
+ println!(" Skipped (already exists): {}", skipped_count);
+ }
+ if error_count > 0 {
+ println!(" Errors (no EXIF data or failures): {}", error_count);
+ }
+
+ Ok(())
+}
diff --git a/src/cleanup/database_updater.rs b/src/cleanup/database_updater.rs
new file mode 100644
index 0000000..052d7ba
--- /dev/null
+++ b/src/cleanup/database_updater.rs
@@ -0,0 +1,154 @@
+use crate::database::{ExifDao, FavoriteDao};
+use crate::tags::TagDao;
+use anyhow::Result;
+use log::{error, info};
+use opentelemetry;
+use std::sync::{Arc, Mutex};
+
+pub struct DatabaseUpdater {
+ tag_dao: Arc>,
+ exif_dao: Arc>,
+ favorites_dao: Arc>,
+}
+
+impl DatabaseUpdater {
+ pub fn new(
+ tag_dao: Arc>,
+ exif_dao: Arc>,
+ favorites_dao: Arc>,
+ ) -> Self {
+ Self {
+ tag_dao,
+ exif_dao,
+ favorites_dao,
+ }
+ }
+
+ /// Update file path across all three database tables
+ /// Returns Ok(()) if successful, continues on partial failures but logs errors
+ pub fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<()> {
+ let context = opentelemetry::Context::current();
+ let mut success_count = 0;
+ let mut error_count = 0;
+
+ // Update tagged_photo table
+ if let Ok(mut dao) = self.tag_dao.lock() {
+ match dao.update_photo_name(old_path, new_path, &context) {
+ Ok(_) => {
+ info!("Updated tagged_photo: {} -> {}", old_path, new_path);
+ success_count += 1;
+ }
+ Err(e) => {
+ error!("Failed to update tagged_photo for {}: {:?}", old_path, e);
+ error_count += 1;
+ }
+ }
+ } else {
+ error!("Failed to acquire lock on TagDao");
+ error_count += 1;
+ }
+
+ // Update image_exif table
+ if let Ok(mut dao) = self.exif_dao.lock() {
+ match dao.update_file_path(&context, old_path, new_path) {
+ Ok(_) => {
+ info!("Updated image_exif: {} -> {}", old_path, new_path);
+ success_count += 1;
+ }
+ Err(e) => {
+ error!("Failed to update image_exif for {}: {:?}", old_path, e);
+ error_count += 1;
+ }
+ }
+ } else {
+ error!("Failed to acquire lock on ExifDao");
+ error_count += 1;
+ }
+
+ // Update favorites table
+ if let Ok(mut dao) = self.favorites_dao.lock() {
+ match dao.update_path(old_path, new_path) {
+ Ok(_) => {
+ info!("Updated favorites: {} -> {}", old_path, new_path);
+ success_count += 1;
+ }
+ Err(e) => {
+ error!("Failed to update favorites for {}: {:?}", old_path, e);
+ error_count += 1;
+ }
+ }
+ } else {
+ error!("Failed to acquire lock on FavoriteDao");
+ error_count += 1;
+ }
+
+ if success_count > 0 {
+ info!(
+ "Updated {}/{} tables for {} -> {}",
+ success_count,
+ success_count + error_count,
+ old_path,
+ new_path
+ );
+ Ok(())
+ } else {
+ Err(anyhow::anyhow!(
+ "Failed to update any tables for {} -> {}",
+ old_path,
+ new_path
+ ))
+ }
+ }
+
+ /// Get all file paths from all three database tables
+ pub fn get_all_file_paths(&mut self) -> Result> {
+ let context = opentelemetry::Context::current();
+ let mut all_paths = Vec::new();
+
+ // Get from tagged_photo
+ if let Ok(mut dao) = self.tag_dao.lock() {
+ match dao.get_all_photo_names(&context) {
+ Ok(paths) => {
+ info!("Found {} paths in tagged_photo", paths.len());
+ all_paths.extend(paths);
+ }
+ Err(e) => {
+ error!("Failed to get paths from tagged_photo: {:?}", e);
+ }
+ }
+ }
+
+ // Get from image_exif
+ if let Ok(mut dao) = self.exif_dao.lock() {
+ match dao.get_all_file_paths(&context) {
+ Ok(paths) => {
+ info!("Found {} paths in image_exif", paths.len());
+ all_paths.extend(paths);
+ }
+ Err(e) => {
+ error!("Failed to get paths from image_exif: {:?}", e);
+ }
+ }
+ }
+
+ // Get from favorites
+ if let Ok(mut dao) = self.favorites_dao.lock() {
+ match dao.get_all_paths() {
+ Ok(paths) => {
+ info!("Found {} paths in favorites", paths.len());
+ all_paths.extend(paths);
+ }
+ Err(e) => {
+ error!("Failed to get paths from favorites: {:?}", e);
+ }
+ }
+ }
+
+ // Deduplicate
+ all_paths.sort();
+ all_paths.dedup();
+
+ info!("Total unique paths across all tables: {}", all_paths.len());
+ Ok(all_paths)
+ }
+}
diff --git a/src/cleanup/file_type_detector.rs b/src/cleanup/file_type_detector.rs
new file mode 100644
index 0000000..401375f
--- /dev/null
+++ b/src/cleanup/file_type_detector.rs
@@ -0,0 +1,103 @@
+use anyhow::{Context, Result};
+use std::fs::File;
+use std::io::Read;
+use std::path::Path;
+
+/// Detect the actual file type by reading the magic number (file header)
+/// Returns the canonical extension for the detected type, or None if unknown
+pub fn detect_file_type(path: &Path) -> Result