PunimTag Web Application - Major Feature Release #1

Open
tanyar09 wants to merge 113 commits from dev into master
356 changed files with 50999 additions and 17147 deletions

26
.env_example Normal file
View File

@ -0,0 +1,26 @@
# PunimTag root environment (copy to ".env" and edit values)
# PostgreSQL (main application DB)
DATABASE_URL=postgresql+psycopg2://punimtag:CHANGE_ME@127.0.0.1:5432/punimtag
# PostgreSQL (auth DB)
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag_auth:CHANGE_ME@127.0.0.1:5432/punimtag_auth
# JWT / bootstrap admin (change these!)
SECRET_KEY=CHANGE_ME_TO_A_LONG_RANDOM_STRING
ADMIN_USERNAME=admin
ADMIN_PASSWORD=CHANGE_ME
# Photo storage
PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads
# Pending viewer uploads (same value as viewer UPLOAD_DIR when using that feature).
# Web transcode cache defaults to a sibling folder: <parent>/web_videos next to
# .../pending-photos (override with WEB_VIDEO_CACHE_DIR if needed).
# UPLOAD_DIR=/mnt/db-server-uploads/pending-photos
# WEB_VIDEO_CACHE_DIR=/mnt/db-server-uploads/web_videos
# Redis (RQ jobs)
REDIS_URL=redis://127.0.0.1:6379/0

View File

@ -0,0 +1,72 @@
# CI Job Status Configuration
This document explains which CI jobs should fail on errors and which are informational.
## Jobs That Should FAIL on Errors ✅
These jobs will show a **red X** if they encounter errors:
### 1. **lint-and-type-check**
- ✅ ESLint (admin-frontend) - **FAILS on lint errors**
- ✅ Type check (viewer-frontend) - **FAILS on type errors**
- ⚠️ npm audit - **Informational only** (continue-on-error: true)
### 2. **python-lint**
- ✅ Python syntax check - **FAILS on syntax errors**
- ✅ Flake8 - **FAILS on style/quality errors**
### 3. **test-backend**
- ✅ pytest - **FAILS on test failures**
- ⚠️ pip-audit - **Informational only** (continue-on-error: true)
### 4. **build**
- ✅ Backend validation (imports/structure) - **FAILS on import errors**
- ✅ npm ci (dependencies) - **FAILS on dependency install errors**
- ✅ npm run build (admin-frontend) - **FAILS on build errors**
- ✅ npm run build (viewer-frontend) - **FAILS on build errors**
- ✅ Prisma client generation - **FAILS on generation errors**
- ⚠️ npm audit - **Informational only** (continue-on-error: true)
## Jobs That Are INFORMATIONAL ⚠️
These jobs will show a **green checkmark** even if they find issues (they're meant to inform, not block):
### 5. **secret-scanning**
- ⚠️ Gitleaks - **Informational** (continue-on-error: true, --exit-code 0)
- Purpose: Report secrets found in codebase, but don't block the build
### 6. **dependency-scan**
- ⚠️ Trivy vulnerability scan - **Informational** (--exit-code 0)
- Purpose: Report HIGH/CRITICAL vulnerabilities, but don't block the build
### 7. **sast-scan**
- ⚠️ Semgrep - **Informational** (continue-on-error: true)
- Purpose: Report security code patterns, but don't block the build
### 8. **workflow-summary**
- ✅ Always runs (if: always())
- Purpose: Generate summary of all job results
## Why Some Jobs Are Informational
Security and dependency scanning jobs are kept as informational because:
1. **False positives** - Security scanners can flag legitimate code
2. **Historical context** - They scan all commits, including old ones
3. **Non-blocking** - Teams can review and fix issues without blocking deployments
4. **Visibility** - Results are still visible in the CI summary and step summaries
## Database Creation
The `|| true` on database creation commands is **intentional**:
- Creating a database that already exists should not fail
- Makes the step idempotent
- Safe to run multiple times
## Summary Step
The test results summary step uses `|| true` for parsing errors:
- Should always complete to show results
- Parsing errors shouldn't fail the job
- Actual test failures are caught by the test step itself

1138
.gitea/workflows/ci.yml Normal file

File diff suppressed because it is too large Load Diff

9
.gitignore vendored
View File

@ -10,7 +10,10 @@ dist/
downloads/
eggs/
.eggs/
# Python lib directories (but not viewer-frontend/lib/ or admin-frontend TS lib/)
lib/
!viewer-frontend/lib/
!admin-frontend/src/lib/
lib64/
parts/
sdist/
@ -55,7 +58,6 @@ Thumbs.db
.history/
photos/
# Photo files and large directories
*.jpg
@ -78,3 +80,8 @@ archive/
demo_photos/
data/uploads/
data/thumbnails/
# PM2 ecosystem config (server-specific paths)
ecosystem.config.js
data/web_videos/

25
.gitleaks.toml Normal file
View File

@ -0,0 +1,25 @@
# Gitleaks configuration file
# This file configures gitleaks to ignore known false positives
title = "PunimTag Gitleaks Configuration"
[allowlist]
description = "Allowlist for known false positives and test files"
# Ignore demo photos directory (contains sample/test HTML files)
paths = [
'''demo_photos/.*''',
]
# Ignore specific commits that contain known false positives
# These are test tokens or sample files, not real secrets
commits = [
"77ffbdcc5041cd732bfcbc00ba513bccb87cfe96", # test_api_auth.py expired_token test
"d300eb1122d12ffb2cdc3fab6dada520b53c20da", # demo_photos/imgres.html sample file
]
# Allowlist specific regex patterns for test files
regexes = [
'''tests/test_api_auth.py.*expired_token.*eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTYwOTQ1NjgwMH0\.invalid''',
]

31
.semgrepignore Normal file
View File

@ -0,0 +1,31 @@
# Semgrep ignore file - suppress false positives and low-risk findings
# Uses gitignore-style patterns
# Console.log format string warnings - false positives
# JavaScript console.log/console.error don't use format strings like printf, so these are safe
admin-frontend/src/pages/PendingPhotos.tsx
admin-frontend/src/pages/Search.tsx
admin-frontend/src/pages/Tags.tsx
viewer-frontend/app/api/users/[id]/route.ts
viewer-frontend/lib/photo-utils.ts
viewer-frontend/lib/video-thumbnail.ts
viewer-frontend/scripts/run-email-verification-migration.ts
# SQL injection warnings - safe uses with controlled inputs (column names, not user data)
# These have nosemgrep comments but also listed here for ignore file
backend/api/auth_users.py
backend/api/pending_linkages.py
# SQL injection warnings in database setup/migration scripts (controlled inputs, admin-only)
scripts/db/
scripts/debug/
# Database setup code in app.py (controlled inputs, admin-only operations)
backend/app.py
# Docker compose security suggestions (acceptable for development)
deploy/docker-compose.yml
# Test files - dummy JWT tokens are expected in tests
tests/test_api_auth.py

93
DEPLOYMENT_CHECKLIST.md Normal file
View File

@ -0,0 +1,93 @@
# Deployment Checklist
After pulling from Git, configure the following server-specific settings:
## 1. Environment Files (gitignored - safe to modify)
### Root `.env`
```bash
# Database connections
DATABASE_URL=postgresql+psycopg2://user:password@10.0.10.181:5432/punimtag
DATABASE_URL_AUTH=postgresql+psycopg2://user:password@10.0.10.181:5432/punimtag_auth
# JWT Secrets
SECRET_KEY=your-secret-key-here
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
# Photo storage
PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads
```
### `admin-frontend/.env`
```bash
VITE_API_URL=http://10.0.10.121:8000
```
### `viewer-frontend/.env`
```bash
DATABASE_URL=postgresql://user:password@10.0.10.181:5432/punimtag
DATABASE_URL_AUTH=postgresql://user:password@10.0.10.181:5432/punimtag_auth
NEXTAUTH_URL=http://10.0.10.121:3001
NEXTAUTH_SECRET=your-secret-key-here
AUTH_URL=http://10.0.10.121:3001
```
## 2. PM2 Configuration
Copy the template and customize for your server:
```bash
cp ecosystem.config.js.example ecosystem.config.js
```
Edit `ecosystem.config.js` and update:
- All `cwd` paths to your deployment directory
- All `error_file` and `out_file` paths to your user's home directory
- `PYTHONPATH` and `PATH` environment variables
## 3. System Configuration (One-time setup)
### Firewall Rules
```bash
sudo ufw allow 3000/tcp # Admin frontend
sudo ufw allow 3001/tcp # Viewer frontend
sudo ufw allow 8000/tcp # Backend API
```
### Database Setup
Create admin user in auth database:
```bash
cd viewer-frontend
npx tsx scripts/fix-admin-user.ts
```
## 4. Build Frontends
```bash
# Admin frontend
cd admin-frontend
npm install
npm run build
# Viewer frontend
cd viewer-frontend
npm install
npm run prisma:generate:all
npm run build
```
## 5. Start Services
```bash
pm2 start ecosystem.config.js
pm2 save
```
## Notes
- `.env` files are gitignored and safe to modify
- `ecosystem.config.js` is gitignored and server-specific
- Database changes (admin user) persist across pulls
- Firewall rules are system-level and persist

View File

@ -1,374 +0,0 @@
`1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111# Merge Request: PunimTag Web Application - Major Feature Release
## Overview
This merge request contains a comprehensive set of changes that transform PunimTag from a desktop GUI application into a modern web-based photo management system with advanced facial recognition capabilities. The changes span from September 2025 to January 2026 and include migration to DeepFace, PostgreSQL support, web frontend implementation, and extensive feature additions.
## Summary Statistics
- **Total Commits**: 200+ commits
- **Files Changed**: 226 files
- **Lines Added**: ~71,189 insertions
- **Lines Removed**: ~1,670 deletions
- **Net Change**: +69,519 lines
- **Date Range**: September 19, 2025 - January 6, 2026
## Key Changes
### 1. Architecture Migration
#### Desktop to Web Migration
- **Removed**: Complete desktop GUI application (Tkinter-based)
- Archive folder with 22+ desktop GUI files removed
- Old photo_tagger.py desktop application removed
- All desktop-specific components archived
- **Added**: Modern web application architecture
- FastAPI backend with RESTful API
- React-based admin frontend
- Next.js-based viewer frontend
- Monorepo structure for unified development
#### Database Migration
- **From**: SQLite database
- **To**: PostgreSQL database
- Dual database architecture (main + auth databases)
- Comprehensive migration scripts
- Database architecture review documentation
- Enhanced data validation and type safety
### 2. Face Recognition Engine Upgrade
#### DeepFace Integration
- **Replaced**: face_recognition library
- **New**: DeepFace with ArcFace model
- 512-dimensional embeddings (4x more detailed)
- Multiple detector options (RetinaFace, MTCNN, OpenCV, SSD)
- Multiple recognition models (ArcFace, Facenet, Facenet512, VGG-Face)
- Improved accuracy and performance
- Pose detection using RetinaFace
- Face quality scoring and filtering
#### Face Processing Enhancements
- EXIF orientation handling`
- Face width detection for profile classification
- Landmarks column for pose detection
- Quality filtering in identification process
- Batch similarity endpoint for efficient face comparison
- Unique faces filter to hide duplicates
- Confidence calibration for realistic match probabilities
### 3. Backend API Development
#### Core API Endpoints
- **Authentication & Authorization**
- JWT-based authentication
- Role-based access control (RBAC)
- User management API
- Password change functionality
- Session management
- **Photo Management**
- Photo upload and import
- Photo search with advanced filters
- Photo tagging and organization
- Bulk operations (delete, tag)
- Favorites functionality
- Media type support (images and videos)
- Date validation and EXIF extraction
- **Face Management**
- Face processing with job queue
- Face identification workflow
- Face similarity matching
- Excluded faces management
- Face quality filtering
- Batch processing support
- **People Management**
- Person creation and identification
- Person search and filtering
- Person modification
- Auto-match functionality
- Pending identifications workflow
- Person statistics and counts
- **Tag Management**
- Tag creation and management
- Photo-tag linkages
- Tag filtering and search
- Bulk tagging operations
- **Video Support**
- Video upload and processing
- Video player modal
- Video metadata extraction
- Video person identification
- **Job Management**
- Background job processing with RQ
- Job status tracking
- Job cancellation support
- Progress updates
- **User Management**
- Admin user management
- Role and permission management
- User activity tracking
- Inactivity timeout
- **Reporting & Moderation**
- Reported photos management
- Pending photos review
- Pending linkages approval
- Identification statistics
### 4. Frontend Development
#### Admin Frontend (React)
- **Scan Page**: Photo import and processing
- Native folder picker integration
- Network path support
- Progress tracking
- Job management
- **Search Page**: Advanced photo search
- Multiple search types (name, date, tags, no_faces, no_tags, processed, unprocessed, favorites)
- Person autocomplete
- Date range filters
- Tag filtering
- Media type filtering
- Pagination
- Session state management
- **Identify Page**: Face identification
- Unidentified faces display
- Person creation and matching
- Quality filtering
- Date filters
- Excluded faces management
- Pagination and navigation
- Setup area toggle
- **AutoMatch Page**: Automated face matching
- Auto-start on mount
- Tolerance configuration
- Quality criteria
- Tag filtering
- Developer mode options
- **Modify Page**: Person modification
- Face selection and unselection
- Person information editing
- Video player modal
- Search filters
- **Tags Page**: Tag management
- Tag creation and editing
- People names integration
- Sorting and filtering
- Tag statistics
- **Faces Maintenance Page**: Face management
- Excluded and identified filters
- Face quality display
- Face deletion
- **User Management Pages**
- User creation and editing
- Role assignment
- Permission management
- Password management
- User activity tracking
- **Reporting & Moderation Pages**
- Pending identifications approval
- Reported photos review
- Pending photos management
- Pending linkages approval
- **UI Enhancements**
- Logo integration
- Emoji page titles
- Password visibility toggle
- Loading progress indicators
- Confirmation dialogs
- Responsive design
- Developer mode features
#### Viewer Frontend (Next.js)
- Photo viewer component with zoom and slideshow
- Photo browsing and navigation
- Tag management interface
- Person identification display
- Favorites functionality
### 5. Infrastructure & DevOps
#### Installation & Setup
- Comprehensive installation script (`install.sh`)
- Automated system dependency installation
- PostgreSQL and Redis setup
- Python virtual environment creation
- Frontend dependency installation
- Environment configuration
- Database initialization
#### Scripts & Utilities
- Database management scripts
- Table creation and migration
- Database backup and restore
- SQLite to PostgreSQL migration
- Auth database setup
- Development utilities
- Face detection debugging
- Pose analysis scripts
- Database diagnostics
- Frontend issue diagnosis
#### Deployment
- Docker Compose configuration
- Backend startup scripts
- Worker process management
- Health check endpoints
### 6. Documentation
#### Technical Documentation
- Architecture documentation
- Database architecture review
- API documentation
- Phase completion summaries
- Migration guides
#### User Documentation
- Comprehensive user guide
- Quick start guides
- Feature documentation
- Installation instructions
#### Analysis Documents
- Video support analysis
- Portrait detection plan
- Auto-match automation plan
- Resource requirements
- Performance analysis
- Client deployment questions
### 7. Testing & Quality Assurance
#### Test Suite
- Face recognition tests
- EXIF extraction tests
- API endpoint tests
- Database migration tests
- Integration tests
#### Code Quality
- Type hints throughout codebase
- Comprehensive error handling
- Input validation
- Security best practices
- Code organization and structure
### 8. Cleanup & Maintenance
#### Repository Cleanup
- Removed archived desktop GUI files (22 files)
- Removed demo photos and resources
- Removed uploaded test files
- Updated .gitignore to prevent re-adding unnecessary files
- Removed obsolete migration files
#### Code Refactoring
- Improved database connection management
- Enhanced error handling
- Better code organization
- Improved type safety
- Performance optimizations
## Breaking Changes
1. **Database**: Migration from SQLite to PostgreSQL is required
2. **API**: New RESTful API replaces desktop GUI
3. **Dependencies**: New system requirements (PostgreSQL, Redis, Node.js)
4. **Configuration**: New environment variables and configuration files
## Migration Path
1. **Database Migration**
- Run PostgreSQL setup script
- Execute SQLite to PostgreSQL migration script
- Verify data integrity
2. **Environment Setup**
- Install system dependencies (PostgreSQL, Redis)
- Run installation script
- Configure environment variables
- Generate Prisma clients
3. **Application Deployment**
- Start PostgreSQL and Redis services
- Run database migrations
- Start backend API
- Start frontend applications
## Testing Checklist
- [x] Database migration scripts tested
- [x] API endpoints functional
- [x] Face recognition accuracy verified
- [x] Frontend components working
- [x] Authentication and authorization tested
- [x] Job processing verified
- [x] Video support tested
- [x] Search functionality validated
- [x] Tag management verified
- [x] User management tested
## Known Issues & Limitations
1. **Performance**: Large photo collections may require optimization
2. **Memory**: DeepFace models require significant memory
3. **Network**: Network path support may vary by OS
4. **Browser**: Some features require modern browsers
## Future Enhancements
- Enhanced video processing
- Advanced analytics and reporting
- Mobile app support
- Cloud storage integration
- Advanced AI features
- Performance optimizations
## Contributors
- Tanya (tatiana.romlit@gmail.com) - Primary developer
- tanyar09 - Initial development
## Related Documentation
- `README.md` - Main project documentation
- `docs/ARCHITECTURE.md` - System architecture
- `docs/DATABASE_ARCHITECTURE_REVIEW.md` - Database design
- `docs/USER_GUIDE.md` - User documentation
- `MONOREPO_MIGRATION.md` - Migration details
## Approval Checklist
- [ ] Code review completed
- [ ] Tests passing
- [ ] Documentation updated
- [ ] Migration scripts tested
- [ ] Performance validated
- [ ] Security review completed
- [ ] Deployment plan reviewed
---
**Merge Request Created**: January 6, 2026
**Base Branch**: `origin/master`
**Target Branch**: `master`
**Status**: Ready for Review

143
QUICK_LOG_REFERENCE.md Normal file
View File

@ -0,0 +1,143 @@
# Quick Log Reference
When something fails, use these commands to quickly check logs.
## 🚀 Quick Commands
### Check All Services for Errors
```bash
./scripts/check-logs.sh
```
Shows PM2 status and recent errors from all services.
### Follow Errors in Real-Time
```bash
./scripts/tail-errors.sh
```
Watches all error logs live (press Ctrl+C to exit).
### View Recent Errors (Last 10 minutes)
```bash
./scripts/view-recent-errors.sh
```
### View Errors from Last 30 minutes
```bash
./scripts/view-recent-errors.sh 30
```
## 📋 PM2 Commands
```bash
# View all logs
pm2 logs
# View specific service logs
pm2 logs punimtag-api
pm2 logs punimtag-worker
pm2 logs punimtag-admin
pm2 logs punimtag-viewer
# View only errors
pm2 logs --err
# Monitor services
pm2 monit
# Check service status
pm2 status
pm2 list
```
## 📁 Log File Locations
All logs are in: `/home/appuser/.pm2/logs/`
- **API**: `punimtag-api-error.log`, `punimtag-api-out.log`
- **Worker**: `punimtag-worker-error.log`, `punimtag-worker-out.log`
- **Admin**: `punimtag-admin-error.log`, `punimtag-admin-out.log`
- **Viewer**: `punimtag-viewer-error.log`, `punimtag-viewer-out.log`
### Click Logs (Admin Frontend)
Click logs are in: `/opt/punimtag/logs/`
- **Click Log**: `admin-clicks.log` (auto-rotates at 10MB, keeps 5 backups)
- **View live clicks**: `tail -f /opt/punimtag/logs/admin-clicks.log`
- **View recent clicks**: `tail -n 100 /opt/punimtag/logs/admin-clicks.log`
- **Search clicks**: `grep "username\|page" /opt/punimtag/logs/admin-clicks.log`
- **Cleanup old logs**: `./scripts/cleanup-click-logs.sh`
**Automated Cleanup (Crontab):**
```bash
# Add to crontab: cleanup logs weekly (Sundays at 2 AM)
0 2 * * 0 /opt/punimtag/scripts/cleanup-click-logs.sh
```
## 🔧 Direct Log Access
```bash
# View last 50 lines of API errors
tail -n 50 /home/appuser/.pm2/logs/punimtag-api-error.log
# Follow worker errors
tail -f /home/appuser/.pm2/logs/punimtag-worker-error.log
# Search for specific errors
grep -i "error\|exception\|traceback" /home/appuser/.pm2/logs/punimtag-*-error.log
```
## 🔄 Log Rotation Setup
Run once to prevent log bloat:
```bash
./scripts/setup-log-rotation.sh
```
This configures:
- Max log size: 50MB (auto-rotates)
- Retain: 7 rotated files
- Compress: Yes
- Daily rotation: Yes (midnight)
## 💡 Troubleshooting Tips
1. **Service keeps crashing?**
```bash
./scripts/check-logs.sh
pm2 logs punimtag-worker --err --lines 100
```
2. **API not responding?**
```bash
pm2 logs punimtag-api --err
pm2 status
```
3. **Large log files?**
```bash
# Check log sizes
du -h /home/appuser/.pm2/logs/*
# Setup rotation if not done
./scripts/setup-log-rotation.sh
```
4. **Need to clear old logs?**
```bash
# PM2 can manage this with rotation, but if needed:
pm2 flush # Clear all logs (be careful!)
```
5. **Viewing click logs?**
```bash
# Watch clicks in real-time
tail -f /opt/punimtag/logs/admin-clicks.log
# View recent clicks
tail -n 100 /opt/punimtag/logs/admin-clicks.log
# Search for specific user or page
grep "admin\|/identify" /opt/punimtag/logs/admin-clicks.log
```

View File

@ -123,20 +123,20 @@ For development, you can use the shared development PostgreSQL server:
- **Host**: 10.0.10.181
- **Port**: 5432
- **User**: ladmin
- **Password**: C0caC0la
- **Password**: [Contact administrator for password]
**Development Server:**
- **Host**: 10.0.10.121
- **User**: appuser
- **Password**: C0caC0la
- **Password**: [Contact administrator for password]
Configure your `.env` file for development:
```bash
# Main database (dev)
DATABASE_URL=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag
# Auth database (dev)
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag_auth
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag_auth
```
**Install PostgreSQL (if not installed):**
@ -201,10 +201,10 @@ DATABASE_URL_AUTH=postgresql+psycopg2://punimtag:punimtag_password@localhost:543
**Development Server:**
```bash
# Main database (dev PostgreSQL server)
DATABASE_URL=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag
# Auth database (dev PostgreSQL server)
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag_auth
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag_auth
```
**Automatic Initialization:**
@ -250,7 +250,7 @@ The separate auth database (`punimtag_auth`) stores frontend website user accoun
# On macOS with Homebrew:
brew install redis
brew services start redis
1
# Verify Redis is running:
redis-cli ping # Should respond with "PONG"
```
@ -819,13 +819,13 @@ The project includes scripts for deploying to the development server.
**Development Server:**
- **Host**: 10.0.10.121
- **User**: appuser
- **Password**: C0caC0la
- **Password**: [Contact administrator for password]
**Development Database:**
- **Host**: 10.0.10.181
- **Port**: 5432
- **User**: ladmin
- **Password**: C0caC0la
- **Password**: [Contact administrator for password]
#### Build and Deploy to Dev

View File

@ -0,0 +1,16 @@
# Admin frontend env (copy to ".env" )
# Backend API origin as seen by the browser. Leave empty for local dev: Vite proxies
# /api to http://127.0.0.1:8000 (see vite.config.ts).
# Production (same host as admin, proxy at /punim-api/): VITE_API_URL=/punim-api
VITE_API_URL=
# Production subpath for static assets (Vite base + React Router basename).
# Local dev: leave unset (served at http://localhost:3000/).
# VITE_BASE_PATH=/punim-admin
# Enable developer mode (shows additional debug info and options)
# Set to "true" to enable, leave empty or unset to disable
VITE_DEVELOPER_MODE=

View File

@ -12,7 +12,7 @@ module.exports = {
},
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json'],
project: ['./tsconfig.json', './tsconfig.node.json'],
},
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
extends: [
@ -27,24 +27,30 @@ module.exports = {
},
},
rules: {
'max-len': [
'max-len': 'off',
'react/react-in-jsx-scope': 'off',
'react/no-unescaped-entities': [
'error',
{
code: 100,
tabWidth: 2,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
forbid: ['>', '}'],
},
],
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'react-hooks/exhaustive-deps': 'warn',
},
overrides: [
{
files: ['**/Help.tsx', '**/Dashboard.tsx'],
rules: {
'react/no-unescaped-entities': 'off',
},
},
],
}

View File

@ -10,6 +10,7 @@
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"axios": "^1.6.2",
"exifr": "^7.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
@ -28,7 +29,7 @@
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.4.0"
"vite": "^7.3.1"
}
},
"node_modules/@alloc/quick-lru": {
@ -347,9 +348,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
@ -360,13 +361,13 @@
"aix"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
@ -377,13 +378,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
@ -394,13 +395,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
@ -411,13 +412,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
@ -428,13 +429,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
@ -445,13 +446,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
@ -462,13 +463,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
@ -479,13 +480,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
@ -496,13 +497,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
@ -513,13 +514,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
@ -530,13 +531,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
@ -547,13 +548,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
@ -564,13 +565,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
@ -581,13 +582,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
@ -598,13 +599,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
@ -615,13 +616,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
@ -632,13 +633,30 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
@ -649,13 +667,30 @@
"netbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
@ -666,13 +701,30 @@
"openbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
@ -683,13 +735,13 @@
"sunos"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
@ -700,13 +752,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
@ -717,13 +769,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
@ -734,7 +786,7 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
@ -2655,9 +2707,9 @@
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -2665,32 +2717,35 @@
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escalade": {
@ -2994,6 +3049,12 @@
"node": ">=0.10.0"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5977,21 +6038,24 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@ -6000,19 +6064,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
@ -6033,9 +6103,46 @@
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -7,11 +7,12 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx"
},
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"axios": "^1.6.2",
"exifr": "^7.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
@ -30,6 +31,6 @@
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.4.0"
"vite": "^7.3.1"
}
}

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<title>Enable Developer Mode</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.success {
color: #10b981;
font-weight: bold;
margin-top: 1rem;
}
button {
background: #3b82f6;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
margin-top: 1rem;
}
button:hover {
background: #2563eb;
}
</style>
</head>
<body>
<div class="container">
<h1>Enable Developer Mode</h1>
<p>Click the button below to enable Developer Mode for PunimTag.</p>
<button onclick="enableDevMode()">Enable Developer Mode</button>
<div id="result"></div>
</div>
<script>
function enableDevMode() {
localStorage.setItem('punimtag_developer_mode', 'true');
const result = document.getElementById('result');
result.innerHTML = '<p class="success">✅ Developer Mode enabled! Redirecting...</p>';
setTimeout(() => {
window.location.href = '/';
}, 1500);
}
// Check if already enabled
if (localStorage.getItem('punimtag_developer_mode') === 'true') {
document.getElementById('result').innerHTML = '<p class="success">✅ Developer Mode is already enabled!</p>';
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

3
admin-frontend/serve.sh Normal file
View File

@ -0,0 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")"
PORT=3000 HOST=0.0.0.0 exec npx --yes serve dist --single

View File

@ -20,9 +20,11 @@ import UserTaggedPhotos from './pages/UserTaggedPhotos'
import ManagePhotos from './pages/ManagePhotos'
import Settings from './pages/Settings'
import Help from './pages/Help'
import VideoPlayer from './pages/VideoPlayer'
import Layout from './components/Layout'
import PasswordChangeModal from './components/PasswordChangeModal'
import AdminRoute from './components/AdminRoute'
import { logClick, flushPendingClicks } from './services/clickLogger'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth()
@ -57,9 +59,49 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
}
function AppRoutes() {
const { isAuthenticated } = useAuth()
// Set up global click logging for authenticated users
useEffect(() => {
if (!isAuthenticated) {
return
}
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target) {
logClick(target)
}
}
// Add click listener
document.addEventListener('click', handleClick, true) // Use capture phase
// Flush pending clicks on page unload
const handleBeforeUnload = () => {
flushPendingClicks()
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
document.removeEventListener('click', handleClick, true)
window.removeEventListener('beforeunload', handleBeforeUnload)
// Flush any pending clicks on cleanup
flushPendingClicks()
}
}, [isAuthenticated])
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/video/:id"
element={
<PrivateRoute>
<VideoPlayer />
</PrivateRoute>
}
/>
<Route
path="/"
element={
@ -129,7 +171,11 @@ function App() {
return (
<AuthProvider>
<DeveloperModeProvider>
<BrowserRouter>
<BrowserRouter
basename={
import.meta.env.BASE_URL.replace(/\/$/, '') || undefined
}
>
<AppRoutes />
</BrowserRouter>
</DeveloperModeProvider>

View File

@ -48,8 +48,12 @@ export const authApi = {
},
me: async (): Promise<UserResponse> => {
const { data } = await apiClient.get<UserResponse>('/api/v1/auth/me')
return data
const response = await apiClient.get<UserResponse>('/api/v1/auth/me')
console.log('🔍 Raw /me API response:', response)
console.log('🔍 Response data:', response.data)
console.log('🔍 Response data type:', typeof response.data)
console.log('🔍 Response data keys:', response.data ? Object.keys(response.data) : 'no keys')
return response.data
},
changePassword: async (

View File

@ -1,9 +1,11 @@
import axios from 'axios'
// Get API base URL from environment variable or use default
// The .env file should contain: VITE_API_URL=http://127.0.0.1:8000
// Alternatively, Vite proxy can be used (configured in vite.config.ts) by setting VITE_API_URL to empty string
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
// API origin as seen by the browser. For local dev, leave VITE_API_URL unset/empty so
// requests use relative /api/v1/... and Vite proxies /api → http://127.0.0.1:8000.
const envApiUrl = import.meta.env.VITE_API_URL
const API_BASE_URL = envApiUrl && envApiUrl.trim() !== ''
? envApiUrl
: '' // Use relative path when empty - works with proxy and HTTPS
export const apiClient = axios.create({
baseURL: API_BASE_URL,
@ -18,6 +20,10 @@ apiClient.interceptors.request.use((config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Remove Content-Type header for FormData - axios will set it automatically with boundary
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
})

View File

@ -39,11 +39,27 @@ export interface SimilarFaceItem {
quality_score: number
filename: string
pose_mode?: string
debug_info?: {
encoding_length: number
encoding_min: number
encoding_max: number
encoding_mean: number
encoding_std: number
encoding_first_10: number[]
}
}
export interface SimilarFacesResponse {
base_face_id: number
items: SimilarFaceItem[]
debug_info?: {
encoding_length: number
encoding_min: number
encoding_max: number
encoding_mean: number
encoding_std: number
encoding_first_10: number[]
}
}
export interface FaceSimilarityPair {
@ -97,6 +113,7 @@ export interface AutoMatchRequest {
tolerance: number
auto_accept?: boolean
auto_accept_threshold?: number
use_distance_based_thresholds?: boolean
}
export interface AutoMatchFaceItem {
@ -217,11 +234,25 @@ export const facesApi = {
})
return response.data
},
getSimilar: async (faceId: number, includeExcluded?: boolean): Promise<SimilarFacesResponse> => {
getSimilar: async (faceId: number, includeExcluded?: boolean, debug?: boolean): Promise<SimilarFacesResponse> => {
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`, {
params: { include_excluded: includeExcluded || false },
params: { include_excluded: includeExcluded || false, debug: debug || false },
})
return response.data
const data = response.data
// Log debug info to browser console if available
if (debug && data.debug_info) {
console.log('🔍 Base Face Encoding Debug Info:', data.debug_info)
}
if (debug && data.items) {
data.items.forEach((item, index) => {
if (item.debug_info) {
console.log(`🔍 Similar Face ${index + 1} (ID: ${item.id}) Encoding Debug Info:`, item.debug_info)
}
})
}
return data
},
batchSimilarity: async (request: BatchSimilarityRequest): Promise<BatchSimilarityResponse> => {
const response = await apiClient.post<BatchSimilarityResponse>('/api/v1/faces/batch-similarity', request)
@ -251,6 +282,7 @@ export const facesApi = {
},
getAutoMatchPeople: async (params?: {
filter_frontal_only?: boolean
tolerance?: number
}): Promise<AutoMatchPeopleResponse> => {
const response = await apiClient.get<AutoMatchPeopleResponse>('/api/v1/faces/auto-match/people', {
params,

View File

@ -27,9 +27,15 @@ export const jobsApi = {
},
streamJobProgress: (jobId: string): EventSource => {
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
// EventSource needs absolute URL - use VITE_API_URL or construct from current origin
// EventSource cannot send custom headers, so we pass token as query parameter
const envApiUrl = import.meta.env.VITE_API_URL
const baseURL = envApiUrl && envApiUrl.trim() !== ''
? envApiUrl
: window.location.origin // Use current origin when empty - works with proxy and HTTPS
const token = localStorage.getItem('access_token')
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}${tokenParam}`)
},
cancelJob: async (jobId: string): Promise<{ message: string; status: string }> => {

View File

@ -46,8 +46,8 @@ export const peopleApi = {
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
return res.data
},
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
const params = lastName ? { last_name: lastName } : {}
listWithFaces: async (name?: string): Promise<PeopleWithFacesListResponse> => {
const params = name ? { last_name: name } : {}
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
return res.data
},

View File

@ -50,11 +50,63 @@ export const photosApi = {
uploadPhotos: async (files: File[]): Promise<UploadResponse> => {
const formData = new FormData()
files.forEach((file) => {
// Extract EXIF date AND original file modification date from each file BEFORE upload
// This preserves the original photo date even if EXIF gets corrupted during upload
// We capture both so we can use modification date as fallback if EXIF is invalid
const exifr = await import('exifr')
// First, append all files and capture modification dates (synchronous operations)
for (const file of files) {
formData.append('files', file)
// ALWAYS capture the original file's modification date before upload
// This is the modification date from the user's system, not the server
if (file.lastModified) {
formData.append(`file_original_mtime_${file.name}`, file.lastModified.toString())
}
}
// Extract EXIF data in parallel for all files (performance optimization)
const exifPromises = files.map(async (file) => {
try {
const exif = await exifr.parse(file, {
pick: ['DateTimeOriginal', 'DateTimeDigitized', 'DateTime'],
translateKeys: false,
translateValues: false,
})
return {
filename: file.name,
exif,
}
} catch (err) {
// EXIF extraction failed, but we still have file.lastModified captured above
console.debug(`EXIF extraction failed for ${file.name}, will use modification date:`, err)
return {
filename: file.name,
exif: null,
}
}
})
// Wait for all EXIF extractions to complete in parallel
const exifResults = await Promise.all(exifPromises)
// Add EXIF dates to form data
for (const result of exifResults) {
if (result.exif?.DateTimeOriginal) {
// Send the EXIF date as metadata
formData.append(`file_exif_date_${result.filename}`, result.exif.DateTimeOriginal)
} else if (result.exif?.DateTime) {
formData.append(`file_exif_date_${result.filename}`, result.exif.DateTime)
} else if (result.exif?.DateTimeDigitized) {
formData.append(`file_exif_date_${result.filename}`, result.exif.DateTimeDigitized)
}
}
// Don't set Content-Type header manually - let the browser set it with boundary
// The interceptor will automatically remove Content-Type for FormData
// Axios will set multipart/form-data with boundary automatically
const { data } = await apiClient.post<UploadResponse>(
'/api/v1/photos/import/upload',
formData
@ -70,9 +122,15 @@ export const photosApi = {
},
streamJobProgress: (jobId: string): EventSource => {
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
// EventSource needs absolute URL - use VITE_API_URL or construct from current origin
// EventSource cannot send custom headers, so we pass token as query parameter
const envApiUrl = import.meta.env.VITE_API_URL
const baseURL = envApiUrl && envApiUrl.trim() !== ''
? envApiUrl
: window.location.origin // Use current origin when empty - works with proxy and HTTPS
const token = localStorage.getItem('access_token')
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}${tokenParam}`)
},
searchPhotos: async (params: {
@ -143,6 +201,27 @@ export const photosApi = {
)
return data
},
browseDirectory: async (path: string): Promise<BrowseDirectoryResponse> => {
// Axios automatically URL-encodes query parameters
const { data } = await apiClient.get<BrowseDirectoryResponse>(
'/api/v1/photos/browse-directory',
{ params: { path } }
)
return data
},
getPhotoImageBlob: async (photoId: number): Promise<string> => {
// Fetch image as blob with authentication
const response = await apiClient.get(
`/api/v1/photos/${photoId}/image`,
{
responseType: 'blob',
}
)
// Create object URL from blob
return URL.createObjectURL(response.data)
},
}
export interface PhotoSearchResult {
@ -152,6 +231,7 @@ export interface PhotoSearchResult {
date_taken?: string
date_added: string
processed: boolean
media_type?: string // "image" or "video"
person_name?: string
tags: string[]
has_faces: boolean
@ -166,3 +246,16 @@ export interface SearchPhotosResponse {
total: number
}
export interface DirectoryItem {
name: string
path: string
is_directory: boolean
is_file: boolean
}
export interface BrowseDirectoryResponse {
current_path: string
parent_path: string | null
items: DirectoryItem[]
}

View File

@ -1,4 +1,5 @@
import apiClient from './client'
import { fastApiV1Path } from '../lib/fastapi-path'
export interface PersonInfo {
id: number
@ -65,6 +66,21 @@ export interface RemoveVideoPersonResponse {
message: string
}
export interface WebPlaybackPrepareResponse {
status: string
message: string
}
export interface WebPlaybackStatusResponse {
status: string
error?: string | null
}
function apiBasePath(): string {
const envApiUrl = import.meta.env.VITE_API_URL
return envApiUrl && envApiUrl.trim() !== '' ? envApiUrl : ''
}
export const videosApi = {
listVideos: async (params: {
page?: number
@ -77,12 +93,14 @@ export const videosApi = {
sort_by?: string
sort_dir?: string
}): Promise<ListVideosResponse> => {
const res = await apiClient.get<ListVideosResponse>('/api/v1/videos', { params })
const res = await apiClient.get<ListVideosResponse>(fastApiV1Path('/videos'), { params })
return res.data
},
getVideoPeople: async (videoId: number): Promise<VideoPeopleResponse> => {
const res = await apiClient.get<VideoPeopleResponse>(`/api/v1/videos/${videoId}/people`)
const res = await apiClient.get<VideoPeopleResponse>(
fastApiV1Path(`/videos/${videoId}/people`)
)
return res.data
},
@ -91,7 +109,7 @@ export const videosApi = {
request: IdentifyVideoRequest
): Promise<IdentifyVideoResponse> => {
const res = await apiClient.post<IdentifyVideoResponse>(
`/api/v1/videos/${videoId}/identify`,
fastApiV1Path(`/videos/${videoId}/identify`),
request
)
return res.data
@ -102,19 +120,42 @@ export const videosApi = {
personId: number
): Promise<RemoveVideoPersonResponse> => {
const res = await apiClient.delete<RemoveVideoPersonResponse>(
`/api/v1/videos/${videoId}/people/${personId}`
fastApiV1Path(`/videos/${videoId}/people/${personId}`)
)
return res.data
},
getThumbnailUrl: (videoId: number): string => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return `${baseURL}/api/v1/videos/${videoId}/thumbnail`
const baseURL = apiBasePath()
return `${baseURL}${fastApiV1Path(`/videos/${videoId}/thumbnail`)}`
},
getVideoUrl: (videoId: number): string => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return `${baseURL}/api/v1/videos/${videoId}/video`
const baseURL = apiBasePath()
return `${baseURL}${fastApiV1Path(`/videos/${videoId}/video`)}`
},
getWebPlaybackStreamUrl: (videoId: number): string => {
const baseURL = apiBasePath()
return `${baseURL}${fastApiV1Path(`/videos/${videoId}/web-playback/stream`)}`
},
prepareWebPlayback: async (
videoId: number
): Promise<WebPlaybackPrepareResponse> => {
const res = await apiClient.post<WebPlaybackPrepareResponse>(
fastApiV1Path(`/videos/${videoId}/web-playback/prepare`)
)
return res.data
},
getWebPlaybackStatus: async (
videoId: number
): Promise<WebPlaybackStatusResponse> => {
const res = await apiClient.get<WebPlaybackStatusResponse>(
fastApiV1Path(`/videos/${videoId}/web-playback/status`)
)
return res.data
},
}

View File

@ -0,0 +1,293 @@
import { useState, useEffect, useCallback } from 'react'
import { photosApi, BrowseDirectoryResponse } from '../api/photos'
interface FolderBrowserProps {
onSelectPath: (path: string) => void
initialPath?: string
onClose: () => void
}
export default function FolderBrowser({
onSelectPath,
initialPath = '/',
onClose,
}: FolderBrowserProps) {
const [currentPath, setCurrentPath] = useState(initialPath)
const [items, setItems] = useState<BrowseDirectoryResponse['items']>([])
const [parentPath, setParentPath] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pathInput, setPathInput] = useState(initialPath)
const loadDirectory = useCallback(async (path: string) => {
setLoading(true)
setError(null)
try {
console.log('Loading directory:', path)
const data = await photosApi.browseDirectory(path)
console.log('Directory loaded:', data)
setCurrentPath(data.current_path)
setPathInput(data.current_path)
setParentPath(data.parent_path)
setItems(data.items)
} catch (err: any) {
console.error('Error loading directory:', err)
console.error('Error response:', err?.response)
console.error('Error status:', err?.response?.status)
console.error('Error data:', err?.response?.data)
// Handle FastAPI validation errors (422) - they have a different structure
let errorMsg = 'Failed to load directory'
if (err?.response?.data) {
const data = err.response.data
// FastAPI validation errors have detail as an array
if (Array.isArray(data.detail)) {
errorMsg = data.detail.map((d: any) => d.msg || JSON.stringify(d)).join(', ')
} else if (typeof data.detail === 'string') {
errorMsg = data.detail
} else if (data.message) {
errorMsg = data.message
} else if (typeof data === 'string') {
errorMsg = data
}
} else if (err?.message) {
errorMsg = err.message
}
setError(errorMsg)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
console.log('FolderBrowser mounted, loading initial path:', initialPath)
loadDirectory(initialPath)
}, [initialPath, loadDirectory])
const handleItemClick = (item: BrowseDirectoryResponse['items'][0]) => {
if (item.is_directory) {
loadDirectory(item.path)
}
}
const handleParentClick = () => {
if (parentPath) {
loadDirectory(parentPath)
}
}
const handlePathInputSubmit = (e: React.FormEvent) => {
e.preventDefault()
loadDirectory(pathInput)
}
const handleSelectCurrentPath = () => {
onSelectPath(currentPath)
onClose()
}
// Build breadcrumb path segments
const pathSegments = currentPath.split('/').filter(Boolean)
const breadcrumbPaths: string[] = []
pathSegments.forEach((_segment, index) => {
const path = '/' + pathSegments.slice(0, index + 1).join('/')
breadcrumbPaths.push(path)
})
console.log('FolderBrowser render - loading:', loading, 'error:', error, 'items:', items.length)
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={(e) => {
// Close modal when clicking backdrop
if (e.target === e.currentTarget) {
onClose()
}
}}
>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col m-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Select Folder
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 focus:outline-none"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/* Path Input */}
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50">
<form onSubmit={handlePathInputSubmit} className="flex gap-2">
<input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
placeholder="Enter or navigate to folder path"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go
</button>
</form>
</div>
{/* Breadcrumb Navigation */}
<div className="px-6 py-2 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-1 text-sm">
<button
onClick={() => loadDirectory('/')}
className="px-2 py-1 text-blue-600 hover:text-blue-800 hover:underline"
>
Root
</button>
{pathSegments.map((segment, index) => (
<span key={index} className="flex items-center gap-1">
<span className="text-gray-400">/</span>
<button
onClick={() => loadDirectory(breadcrumbPaths[index])}
className="px-2 py-1 text-blue-600 hover:text-blue-800 hover:underline"
>
{segment}
</button>
</span>
))}
</div>
</div>
{/* Error Message */}
{error && (
<div className="px-6 py-3 bg-red-50 border-b border-red-200">
<p className="text-sm text-red-800">{String(error)}</p>
</div>
)}
{/* Directory Listing */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500">Loading...</div>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500">Directory is empty</div>
</div>
) : (
<div className="space-y-1">
{parentPath && (
<button
onClick={handleParentClick}
className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none focus:bg-gray-100 flex items-center gap-2"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
<span className="text-gray-700 font-medium">.. (Parent)</span>
</button>
)}
{items.map((item) => (
<button
key={item.path}
onClick={() => handleItemClick(item)}
className={`w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none focus:bg-gray-100 flex items-center gap-2 ${
item.is_directory ? 'cursor-pointer' : 'cursor-default opacity-60'
}`}
disabled={!item.is_directory}
>
{item.is_directory ? (
<svg
className="w-5 h-5 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
) : (
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
)}
<span className="text-gray-700">{item.name}</span>
</button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
<div className="text-sm text-gray-600">
<span className="font-medium">Current path:</span>{' '}
<span className="font-mono">{currentPath}</span>
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
<button
onClick={handleSelectCurrentPath}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Select This Folder
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -5,6 +5,12 @@ import { useInactivityTimeout } from '../hooks/useInactivityTimeout'
const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000
// Check if running on iOS
const isIOS = (): boolean => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
type NavItem = {
path: string
label: string
@ -16,6 +22,8 @@ export default function Layout() {
const location = useLocation()
const { username, logout, isAuthenticated, hasPermission } = useAuth()
const [maintenanceExpanded, setMaintenanceExpanded] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(false)
const isIOSDevice = isIOS()
const handleInactivityLogout = useCallback(() => {
logout()
@ -60,6 +68,12 @@ export default function Layout() {
<Link
key={item.path}
to={item.path}
onClick={() => {
// Close sidebar on iOS when navigating
if (isIOSDevice) {
setSidebarOpen(false)
}
}}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
} ${extraClasses}`}
@ -103,24 +117,40 @@ export default function Layout() {
<div className="bg-white border-b border-gray-200 shadow-sm">
<div className="flex">
{/* Left sidebar - fixed position with logo */}
<div className="fixed left-0 top-0 w-64 bg-white border-r border-gray-200 h-20 flex items-center justify-center px-4 z-10">
<Link to="/" className="flex items-center justify-center hover:opacity-80 transition-opacity">
<img
src="/logo.png"
alt="PunimTag"
className="h-12 w-auto"
onError={(e) => {
// Fallback if logo.png doesn't exist, try logo.svg
const target = e.target as HTMLImageElement
if (target.src.endsWith('logo.png')) {
target.src = '/logo.svg'
}
}}
/>
</Link>
<div className={`${isIOSDevice ? 'w-20' : 'w-64'} fixed left-0 top-0 bg-white border-r border-gray-200 h-20 flex items-center justify-center px-4 z-10`}>
{isIOSDevice ? (
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center p-2 hover:bg-gray-100 rounded-lg transition-colors"
aria-label="Toggle menu"
>
<svg className="w-6 h-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{sidebarOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
) : (
<Link to="/" className="flex items-center justify-center hover:opacity-80 transition-opacity">
<img
src="/logo.png"
alt="PunimTag"
className="h-12 w-auto"
onError={(e) => {
// Fallback if logo.png doesn't exist, try logo.svg
const target = e.target as HTMLImageElement
if (target.src.endsWith('logo.png')) {
target.src = '/logo.svg'
}
}}
/>
</Link>
)}
</div>
{/* Header content - aligned with main content */}
<div className="ml-64 flex-1 px-4">
<div className={`${isIOSDevice ? 'ml-20' : 'ml-64'} flex-1 px-4`}>
<div className="flex justify-between items-center h-20">
<div className="flex items-center">
<h1 className="text-lg font-bold text-gray-900">{getPageTitle()}</h1>
@ -140,8 +170,22 @@ export default function Layout() {
</div>
<div className="flex relative">
{/* Overlay for mobile when sidebar is open */}
{isIOSDevice && sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Left sidebar - fixed position */}
<div className="fixed left-0 top-20 w-64 bg-white border-r border-gray-200 h-[calc(100vh-5rem)] overflow-y-auto">
<div
className={`fixed left-0 top-20 bg-white border-r border-gray-200 h-[calc(100vh-5rem)] overflow-y-auto transition-transform duration-300 z-30 ${
isIOSDevice
? `w-64 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: 'w-64'
}`}
>
<nav className="p-4 space-y-1">
{visiblePrimary.map((item) => renderNavLink(item))}
@ -172,7 +216,7 @@ export default function Layout() {
</div>
{/* Main content - with left margin to account for fixed sidebar */}
<div className="flex-1 ml-64 p-4">
<div className={`flex-1 ${isIOSDevice ? 'ml-20' : 'ml-64'} p-4`}>
<Outlet />
</div>
</div>

View File

@ -1,6 +1,9 @@
import { useEffect, useState, useRef } from 'react'
import { PhotoSearchResult, photosApi } from '../api/photos'
import { apiClient } from '../api/client'
import videosApi from '../api/videos'
import { useWebPlaybackVideo } from '../hooks/useWebPlaybackVideo'
import { isRemoteMediaPath } from '../lib/media-path'
interface PhotoViewerProps {
photos: PhotoSearchResult[]
@ -36,7 +39,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
// Slideshow state
const [isPlaying, setIsPlaying] = useState(false)
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
const slideshowTimerRef = useRef<NodeJS.Timeout | null>(null)
const slideshowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Favorite state
const [isFavorite, setIsFavorite] = useState(false)
@ -46,29 +49,44 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
const canGoPrev = currentIndex > 0
const canGoNext = currentIndex < photos.length - 1
// Get photo URL
const getPhotoUrl = (photoId: number) => {
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
// Check if current photo is a video
const isVideo = (photo: PhotoSearchResult) => {
return photo.media_type === 'video'
}
// Preload adjacent images
const currentIsVideo = currentPhoto ? isVideo(currentPhoto) : false
const webPlayback = useWebPlaybackVideo(
currentPhoto && currentIsVideo ? currentPhoto.id : null,
currentPhoto && currentIsVideo ? currentPhoto.path : ''
)
const getImageUrl = (photoId: number) =>
`${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
// Preload adjacent images (skip videos)
const preloadAdjacent = (index: number) => {
// Preload next photo
// Preload next photo (only if it's an image)
if (index + 1 < photos.length) {
const nextPhotoId = photos[index + 1].id
if (!preloadedImages.current.has(nextPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(nextPhotoId)
preloadedImages.current.add(nextPhotoId)
const nextPhoto = photos[index + 1]
if (!isVideo(nextPhoto)) {
const nextPhotoId = nextPhoto.id
if (!preloadedImages.current.has(nextPhotoId)) {
const img = new Image()
img.src = getImageUrl(nextPhotoId)
preloadedImages.current.add(nextPhotoId)
}
}
}
// Preload previous photo
// Preload previous photo (only if it's an image)
if (index - 1 >= 0) {
const prevPhotoId = photos[index - 1].id
if (!preloadedImages.current.has(prevPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(prevPhotoId)
preloadedImages.current.add(prevPhotoId)
const prevPhoto = photos[index - 1]
if (!isVideo(prevPhoto)) {
const prevPhotoId = prevPhoto.id
if (!preloadedImages.current.has(prevPhotoId)) {
const img = new Image()
img.src = getImageUrl(prevPhotoId)
preloadedImages.current.add(prevPhotoId)
}
}
}
}
@ -177,7 +195,9 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
useEffect(() => {
if (!currentPhoto) return
setImageLoading(true)
if (currentPhoto.media_type !== 'video') {
setImageLoading(true)
}
setImageError(false)
// Reset zoom when photo changes
setZoom(1)
@ -196,6 +216,52 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
preloadAdjacent(currentIndex)
}, [currentIndex, currentPhoto, photos.length])
// Sync loading / error for local web playback (videos)
useEffect(() => {
if (!currentPhoto || currentPhoto.media_type !== 'video') {
return
}
if (webPlayback.error) {
setImageError(true)
setImageLoading(false)
return
}
if (!webPlayback.videoSrc || webPlayback.preparing) {
setImageLoading(true)
setImageError(false)
return
}
setImageError(false)
setImageLoading(true)
}, [
currentPhoto?.id,
currentPhoto?.media_type,
webPlayback.error,
webPlayback.preparing,
webPlayback.videoSrc,
])
// Prefetch browser-safe transcode for nearby local videos (viewer parity)
useEffect(() => {
if (photos.length === 0) {
return
}
const idxs = [
currentIndex - 2,
currentIndex - 1,
currentIndex + 1,
currentIndex + 2,
].filter((i) => i >= 0 && i < photos.length)
for (const i of idxs) {
const p = photos[i]
if (!isVideo(p) || isRemoteMediaPath(p.path)) {
continue
}
videosApi.prepareWebPlayback(p.id).catch(() => {})
}
}, [currentIndex, photos])
// Toggle favorite
const toggleFavorite = async () => {
if (loadingFavorite || !currentPhoto) return
@ -258,7 +324,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
return null
}
const photoUrl = getPhotoUrl(currentPhoto.id)
const imagePhotoUrl = getImageUrl(currentPhoto.id)
return (
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
@ -330,16 +396,16 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
</div>
</div>
{/* Main Image Area */}
{/* Main Image/Video Area */}
<div
ref={imageContainerRef}
className="flex-1 flex items-center justify-center relative overflow-hidden"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
onWheel={currentIsVideo ? undefined : handleWheel}
onMouseDown={currentIsVideo ? undefined : handleMouseDown}
onMouseMove={currentIsVideo ? undefined : handleMouseMove}
onMouseUp={currentIsVideo ? undefined : handleMouseUp}
onMouseLeave={currentIsVideo ? undefined : handleMouseUp}
style={{ cursor: currentIsVideo ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default') }}
>
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
@ -348,9 +414,36 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
)}
{imageError ? (
<div className="text-white text-center">
<div className="text-lg mb-2">Failed to load image</div>
<div className="text-lg mb-2">Failed to load {currentIsVideo ? 'video' : 'image'}</div>
{webPlayback.error && (
<div className="text-sm text-red-300 mb-2">{webPlayback.error}</div>
)}
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
</div>
) : currentIsVideo ? (
<div className="relative h-full w-full max-h-[calc(90vh-80px)] max-w-full flex items-center justify-center">
<video
key={currentPhoto.id}
src={webPlayback.videoSrc || undefined}
className="object-contain w-full h-full max-w-full max-h-full"
controls={true}
controlsList="nodownload"
style={{
display: 'block',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
}}
onLoadedData={() => {
setImageLoading(false)
}}
onError={() => {
setImageLoading(false)
setImageError(true)
}}
/>
</div>
) : (
<div
style={{
@ -359,7 +452,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
}}
>
<img
src={photoUrl}
src={imagePhotoUrl}
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain"
style={{ userSelect: 'none', pointerEvents: 'none' }}
@ -373,37 +466,39 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
</div>
)}
{/* Zoom Controls */}
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
<button
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom in (Ctrl/Cmd + Wheel)"
>
+
</button>
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
{Math.round(zoom * 100)}%
</div>
<button
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom out (Ctrl/Cmd + Wheel)"
>
</button>
{zoom !== 1 && (
{/* Zoom Controls (hidden for videos) */}
{!currentIsVideo && (
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
<button
onClick={resetZoom}
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
title="Reset zoom"
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom in (Ctrl/Cmd + Wheel)"
>
Reset
+
</button>
)}
</div>
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
{Math.round(zoom * 100)}%
</div>
<button
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom out (Ctrl/Cmd + Wheel)"
>
</button>
{zoom !== 1 && (
<button
onClick={resetZoom}
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
title="Reset zoom"
>
Reset
</button>
)}
</div>
)}
{/* Navigation Buttons */}
<button

View File

@ -0,0 +1,51 @@
import { forwardRef, type VideoHTMLAttributes } from 'react'
import { useWebPlaybackVideo } from '../hooks/useWebPlaybackVideo'
export interface WebPlaybackVideoProps
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, 'src'> {
videoId: number
/** Filesystem or remote URL from the photo/video record */
mediaPath: string
}
/**
* HTML5 video that uses browser-safe web playback for local files (H.264/AAC transcode)
* and direct URL for remote http(s) sources.
*/
export const WebPlaybackVideo = forwardRef<HTMLVideoElement, WebPlaybackVideoProps>(
function WebPlaybackVideo(
{ videoId, mediaPath, className, onLoadedData, onError, ...rest },
ref
) {
const { videoSrc, preparing, error } = useWebPlaybackVideo(videoId, mediaPath)
return (
<div className="relative w-full">
{error && (
<div className="mb-2 rounded bg-red-900/80 px-2 py-1 text-sm text-red-100">
{error}
</div>
)}
{preparing && !videoSrc && !error && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/50 text-sm text-white">
Preparing video
</div>
)}
<video
ref={ref}
src={videoSrc || undefined}
className={className}
onLoadedData={(e) => {
onLoadedData?.(e)
}}
onError={(e) => {
if (!error) {
onError?.(e)
}
}}
{...rest}
/>
</div>
)
}
)

View File

@ -38,6 +38,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authApi
.me()
.then((user) => {
console.log('🔍 Auth /me response:', {
username: user.username,
is_admin: user.is_admin,
role: user.role,
permissions: user.permissions
})
setAuthState({
isAuthenticated: true,
username: user.username,
@ -76,10 +82,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = async (username: string, password: string) => {
try {
setAuthState((prev) => ({ ...prev, isLoading: true }))
const tokens: TokenResponse = await authApi.login({ username, password })
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
const user = await authApi.me()
console.log('🔍 Login /me response:', {
username: user.username,
is_admin: user.is_admin,
role: user.role,
permissions: user.permissions
})
const passwordChangeRequired = tokens.password_change_required || false
setAuthState({
isAuthenticated: true,
@ -92,9 +105,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
return { success: true, passwordChangeRequired }
} catch (error: any) {
setAuthState((prev) => ({ ...prev, isLoading: false }))
console.error('Login error:', error)
return {
success: false,
error: error.response?.data?.detail || 'Login failed',
error: error.response?.data?.detail || error.message || 'Login failed',
}
}
}
@ -130,7 +145,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (authState.isAdmin) {
return true
}
return Boolean(authState.permissions[featureKey])
const hasPerm = Boolean(authState.permissions[featureKey])
console.log(`🔍 hasPermission(${featureKey}):`, {
isAdmin: authState.isAdmin,
hasPerm,
permissions: authState.permissions
})
return hasPerm
},
[authState.isAdmin, authState.permissions]
)

View File

@ -1,32 +1,17 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { createContext, useContext, ReactNode } from 'react'
interface DeveloperModeContextType {
isDeveloperMode: boolean
setDeveloperMode: (enabled: boolean) => void
}
const DeveloperModeContext = createContext<DeveloperModeContextType | undefined>(undefined)
const STORAGE_KEY = 'punimtag_developer_mode'
// Check environment variable (set at build time)
const isDeveloperMode = import.meta.env.VITE_DEVELOPER_MODE === 'true'
export function DeveloperModeProvider({ children }: { children: ReactNode }) {
const [isDeveloperMode, setIsDeveloperMode] = useState<boolean>(false)
// Load from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored !== null) {
setIsDeveloperMode(stored === 'true')
}
}, [])
const setDeveloperMode = (enabled: boolean) => {
setIsDeveloperMode(enabled)
localStorage.setItem(STORAGE_KEY, enabled.toString())
}
return (
<DeveloperModeContext.Provider value={{ isDeveloperMode, setDeveloperMode }}>
<DeveloperModeContext.Provider value={{ isDeveloperMode }}>
{children}
</DeveloperModeContext.Provider>
)

View File

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import videosApi from '../api/videos'
import { isRemoteMediaPath } from '../lib/media-path'
const POLL_MS = 1000
const MAX_POLLS = 900
function formatPrepareError(err: unknown): string {
if (err && typeof err === 'object' && 'response' in err) {
const r = (err as { response?: { data?: { detail?: string }; status?: number } })
.response
if (r?.data?.detail) {
return String(r.data.detail)
}
if (r?.status === 503) {
return 'Video prep unavailable (Redis or worker may be down).'
}
}
if (err instanceof Error) {
return err.message
}
return 'Video playback failed'
}
/**
* Resolve a URL for HTML5 video: remote paths use the URL as-is; local files use
* browser-safe transcoded stream after prepare + poll (same behavior as viewer).
*/
export function useWebPlaybackVideo(
videoId: number | null,
mediaPath: string
): { videoSrc: string | null; preparing: boolean; error: string | null } {
const [videoSrc, setVideoSrc] = useState<string | null>(null)
const [preparing, setPreparing] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (videoId === null) {
setVideoSrc(null)
setPreparing(false)
setError(null)
return
}
let cancelled = false
if (isRemoteMediaPath(mediaPath)) {
setVideoSrc(mediaPath)
setPreparing(false)
setError(null)
return () => {
cancelled = true
}
}
const playbackId = videoId
async function run(): Promise<void> {
const id = playbackId
setPreparing(true)
setError(null)
setVideoSrc(null)
try {
const prep = await videosApi.prepareWebPlayback(id)
if (cancelled) {
return
}
if (prep.status === 'ready') {
setVideoSrc(videosApi.getWebPlaybackStreamUrl(id))
setPreparing(false)
return
}
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
if (cancelled) {
return
}
await new Promise((r) => setTimeout(r, POLL_MS))
const st = await videosApi.getWebPlaybackStatus(id)
if (cancelled) {
return
}
if (st.status === 'ready') {
setVideoSrc(videosApi.getWebPlaybackStreamUrl(id))
setPreparing(false)
return
}
if (st.status === 'failed') {
throw new Error(st.error || 'Video transcode failed')
}
}
throw new Error('Video preparation timed out')
} catch (e) {
if (!cancelled) {
setError(formatPrepareError(e))
setPreparing(false)
}
}
}
void run()
return () => {
cancelled = true
}
}, [videoId, mediaPath])
return { videoSrc, preparing, error }
}

View File

@ -0,0 +1,7 @@
/**
* Relative URL under FastAPI's versioned mount (backend uses prefix="/api/v1").
*/
export function fastApiV1Path(suffix: string): string {
const p = suffix.startsWith('/') ? suffix : `/${suffix}`
return `/api/v1${p}`
}

View File

@ -0,0 +1,5 @@
/** True if the library path points at a remote URL (browser plays directly). */
export function isRemoteMediaPath(path: string): boolean {
const t = path.trim().toLowerCase()
return t.startsWith('http://') || t.startsWith('https://')
}

View File

@ -159,10 +159,7 @@ export default function ApproveIdentified() {
}
}, [dateFrom, dateTo])
const handleOpenReport = () => {
setShowReport(true)
loadReport()
}
// Removed unused handleOpenReport function
const handleCloseReport = () => {
setShowReport(false)
@ -337,7 +334,7 @@ export default function ApproveIdentified() {
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
loading="lazy"
@ -356,7 +353,7 @@ export default function ApproveIdentified() {
</div>
) : (
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
loading="lazy"

View File

@ -7,7 +7,8 @@ import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
import { useDeveloperMode } from '../context/DeveloperModeContext'
const DEFAULT_TOLERANCE = 0.6
const DEFAULT_TOLERANCE = 0.6 // Default for regular auto-match (more lenient)
const RUN_AUTO_MATCH_TOLERANCE = 0.5 // Tolerance for Run auto-match button (stricter)
export default function AutoMatch() {
const { isDeveloperMode } = useDeveloperMode()
@ -16,8 +17,8 @@ export default function AutoMatch() {
const [isActive, setIsActive] = useState(false)
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
// Store matches separately, keyed by person_id
const [matchesCache, setMatchesCache] = useState<Record<number, AutoMatchFaceItem[]>>({})
// Store matches separately, keyed by person_id_tolerance (composite key)
const [matchesCache, setMatchesCache] = useState<Record<string, AutoMatchFaceItem[]>>({})
const [currentIndex, setCurrentIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [allPeople, setAllPeople] = useState<Person[]>([])
@ -44,6 +45,8 @@ export default function AutoMatch() {
const [stateRestored, setStateRestored] = useState(false)
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
const restorationCompleteRef = useRef(false)
// Track current tolerance in a ref to avoid stale closures
const toleranceRef = useRef(tolerance)
const currentPerson = useMemo(() => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
@ -52,30 +55,49 @@ export default function AutoMatch() {
const currentMatches = useMemo(() => {
if (!currentPerson) return []
return matchesCache[currentPerson.person_id] || []
}, [currentPerson, matchesCache])
// Use ref tolerance to ensure we always get the current tolerance value
const currentTolerance = toleranceRef.current
const cacheKey = `${currentPerson.person_id}_${currentTolerance}`
return matchesCache[cacheKey] || []
}, [currentPerson, matchesCache, tolerance]) // Keep tolerance in deps to trigger recalculation when it changes
// Check if any matches are selected
const hasSelectedMatches = useMemo(() => {
return currentMatches.some(match => selectedFaces[match.id] === true)
return currentMatches.some((match: AutoMatchFaceItem) => selectedFaces[match.id] === true)
}, [currentMatches, selectedFaces])
// Update tolerance ref whenever tolerance changes
useEffect(() => {
toleranceRef.current = tolerance
}, [tolerance])
// Load matches for a specific person (lazy loading)
const loadPersonMatches = async (personId: number) => {
// Skip if already cached
if (matchesCache[personId]) {
return
const loadPersonMatches = async (personId: number, currentTolerance?: number) => {
// Use provided tolerance, or ref tolerance (always current), or state tolerance as fallback
const toleranceToUse = currentTolerance !== undefined ? currentTolerance : toleranceRef.current
// Create cache key that includes tolerance to avoid stale matches
const cacheKey = `${personId}_${toleranceToUse}`
// Double-check: if tolerance changed, don't use cached value
if (toleranceToUse !== toleranceRef.current) {
// Tolerance changed since this was called, don't use cache
// Will fall through to load fresh matches
} else {
// Skip if already cached for this tolerance
if (matchesCache[cacheKey]) {
return
}
}
try {
const response = await facesApi.getAutoMatchPersonMatches(personId, {
tolerance,
tolerance: toleranceToUse,
filter_frontal_only: false
})
setMatchesCache(prev => ({
...prev,
[personId]: response.matches
[cacheKey]: response.matches
}))
// Update total_matches in people list
@ -106,9 +128,10 @@ export default function AutoMatch() {
} catch (error) {
console.error('Failed to load matches for person:', error)
// Set empty matches on error, and remove person from list
// Use composite cache key
setMatchesCache(prev => ({
...prev,
[personId]: []
[cacheKey]: []
}))
// Remove person if matches failed to load (assume no matches)
setPeople(prev => prev.filter(p => p.person_id !== personId))
@ -118,7 +141,10 @@ export default function AutoMatch() {
// Shared function for auto-load and refresh (loads people list only - fast)
const loadAutoMatch = async (clearState: boolean = false) => {
if (tolerance < 0 || tolerance > 1) {
// Use ref to get current tolerance (avoids stale closure)
const currentTolerance = toleranceRef.current
if (currentTolerance < 0 || currentTolerance > 1) {
return
}
@ -128,12 +154,30 @@ export default function AutoMatch() {
// Clear saved state if explicitly requested (Refresh button)
if (clearState) {
sessionStorage.removeItem(STATE_KEY)
setMatchesCache({}) // Clear matches cache
// Clear ALL cache entries
setMatchesCache({})
} else {
// Also clear any cache entries that don't match current tolerance (even if not explicitly clearing)
setMatchesCache(prev => {
const cleaned: Record<string, AutoMatchFaceItem[]> = {}
// Only keep cache entries that match current tolerance
Object.keys(prev).forEach(key => {
const parts = key.split('_')
if (parts.length >= 2) {
const cachedTolerance = parseFloat(parts[parts.length - 1])
if (!isNaN(cachedTolerance) && cachedTolerance === currentTolerance) {
cleaned[key] = prev[key]
}
}
})
return cleaned
})
}
// Load people list only (fast - no match calculations)
const response = await facesApi.getAutoMatchPeople({
filter_frontal_only: false
filter_frontal_only: false,
tolerance: currentTolerance
})
if (response.people.length === 0) {
@ -154,9 +198,9 @@ export default function AutoMatch() {
setOriginalSelectedFaces({})
setIsActive(true)
// Load matches for first person immediately
// Load matches for first person immediately with current tolerance
if (response.people.length > 0) {
await loadPersonMatches(response.people[0].person_id)
await loadPersonMatches(response.people[0].person_id, currentTolerance)
}
} catch (error) {
console.error('Auto-match failed:', error)
@ -180,7 +224,6 @@ export default function AutoMatch() {
} finally {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load state from sessionStorage on mount (people, current index, selected faces)
@ -262,7 +305,7 @@ export default function AutoMatch() {
const matchesCacheRef = useRef(matchesCache)
const isActiveRef = useRef(isActive)
const hasNoResultsRef = useRef(hasNoResults)
const toleranceRef = useRef(tolerance)
// Note: toleranceRef is already declared above, don't redeclare
// Update refs whenever state changes
useEffect(() => {
@ -356,7 +399,15 @@ export default function AutoMatch() {
if (initialLoadRef.current && restorationCompleteRef.current) {
// Clear matches cache when tolerance changes (matches depend on tolerance)
setMatchesCache({})
loadAutoMatch()
// Clear people list to force fresh load with new tolerance
setPeople([])
setFilteredPeople([])
setSelectedFaces({})
setOriginalSelectedFaces({})
setCurrentIndex(0)
setIsActive(false)
// Reload with new tolerance
loadAutoMatch(true) // Pass true to clear sessionStorage as well
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tolerance])
@ -398,12 +449,29 @@ export default function AutoMatch() {
return
}
// Show informational message about bulk operation
const infoMessage = [
' Bulk Auto-Match Operation',
'',
'This operation will automatically match faces across your entire photo library.',
'While the system uses advanced matching algorithms, some matches may not be 100% accurate.',
'',
'Please review the results after completion to ensure accuracy.',
'',
'Do you want to proceed with the auto-match operation?'
].join('\n')
if (!confirm(infoMessage)) {
return
}
setBusy(true)
try {
const response = await facesApi.autoMatch({
tolerance,
tolerance: RUN_AUTO_MATCH_TOLERANCE, // Use 0.5 for Run auto-match button (stricter)
auto_accept: true,
auto_accept_threshold: autoAcceptThreshold
auto_accept_threshold: autoAcceptThreshold,
use_distance_based_thresholds: true // Enable distance-based thresholds for Run auto-match button
})
// Show summary if auto-accept was performed
@ -458,7 +526,7 @@ export default function AutoMatch() {
const selectAll = () => {
const newSelected: Record<number, boolean> = {}
currentMatches.forEach(match => {
currentMatches.forEach((match: AutoMatchFaceItem) => {
newSelected[match.id] = true
})
setSelectedFaces(newSelected)
@ -466,7 +534,7 @@ export default function AutoMatch() {
const clearAll = () => {
const newSelected: Record<number, boolean> = {}
currentMatches.forEach(match => {
currentMatches.forEach((match: AutoMatchFaceItem) => {
newSelected[match.id] = false
})
setSelectedFaces(newSelected)
@ -478,14 +546,14 @@ export default function AutoMatch() {
setSaving(true)
try {
const faceIds = currentMatches
.filter(match => selectedFaces[match.id] === true)
.map(match => match.id)
.filter((match: AutoMatchFaceItem) => selectedFaces[match.id] === true)
.map((match: AutoMatchFaceItem) => match.id)
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
// Update original selected faces to current state
const newOriginal: Record<number, boolean> = {}
currentMatches.forEach(match => {
currentMatches.forEach((match: AutoMatchFaceItem) => {
newOriginal[match.id] = selectedFaces[match.id] || false
})
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
@ -499,33 +567,45 @@ export default function AutoMatch() {
}
}
// Load matches when current person changes (lazy loading)
// Load matches when current person changes OR tolerance changes (lazy loading)
useEffect(() => {
if (currentPerson && restorationCompleteRef.current) {
loadPersonMatches(currentPerson.person_id)
// Always use ref tolerance (always current) to avoid stale matches
const currentTolerance = toleranceRef.current
// Force reload when tolerance changes - clear cache for this person first
const cacheKey = `${currentPerson.person_id}_${currentTolerance}`
if (!matchesCache[cacheKey]) {
// Only load if not already cached for current tolerance
loadPersonMatches(currentPerson.person_id, currentTolerance)
}
// Preload matches for next person in background
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
if (currentIndex + 1 < activePeople.length) {
const nextPerson = activePeople[currentIndex + 1]
loadPersonMatches(nextPerson.person_id)
const nextCacheKey = `${nextPerson.person_id}_${currentTolerance}`
if (!matchesCache[nextCacheKey]) {
loadPersonMatches(nextPerson.person_id, currentTolerance)
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPerson?.person_id, currentIndex])
}, [currentPerson?.person_id, currentIndex, tolerance])
// Restore selected faces when navigating to a different person
useEffect(() => {
if (currentPerson) {
const matches = matchesCache[currentPerson.person_id] || []
const cacheKey = `${currentPerson.person_id}_${tolerance}`
const matches = matchesCache[cacheKey] || []
const restored: Record<number, boolean> = {}
matches.forEach(match => {
matches.forEach((match: AutoMatchFaceItem) => {
restored[match.id] = originalSelectedFaces[match.id] || false
})
setSelectedFaces(restored)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache])
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache, tolerance])
const goBack = () => {
if (currentIndex > 0) {
@ -696,7 +776,7 @@ export default function AutoMatch() {
)}
</div>
<div className="mt-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded p-2">
<span className="font-medium"> Auto-Match Criteria:</span> Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
<span className="font-medium"> Auto-Match Criteria:</span> Only faces with similarity higher than 85% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
</div>
</div>
@ -807,7 +887,7 @@ export default function AutoMatch() {
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${currentPerson.reference_face_id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentPerson.reference_face_id}/crop`}
alt="Reference face"
className="max-w-[300px] max-h-[300px] rounded border border-gray-300"
/>
@ -876,7 +956,7 @@ export default function AutoMatch() {
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${match.id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${match.id}/crop`}
alt="Match face"
className="w-20 h-20 object-cover rounded border border-gray-300"
/>

View File

@ -4,7 +4,7 @@ import { photosApi, PhotoSearchResult } from '../api/photos'
import apiClient from '../api/client'
export default function Dashboard() {
const { username } = useAuth()
const { username: _username } = useAuth()
const [samplePhotos, setSamplePhotos] = useState<PhotoSearchResult[]>([])
const [loadingPhotos, setLoadingPhotos] = useState(true)
@ -261,36 +261,6 @@ export default function Dashboard() {
)}
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 bg-white">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl md:text-5xl font-bold mb-6" style={{ color: '#F97316' }}>
Ready to Get Started?
</h2>
<p className="text-xl mb-8 max-w-2xl mx-auto text-gray-600">
Begin organizing your photo collection today. Use the navigation menu
to explore all the powerful features PunimTag has to offer.
</p>
<div className="flex flex-wrap justify-center gap-4">
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🗂</span> Scan Photos
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold"></span> Process Faces
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">👤</span> Identify People
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🤖</span> Auto-Match
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🔍</span> Search Photos
</div>
</div>
</div>
</section>
</div>
)
}

View File

@ -167,39 +167,95 @@ function ScanPageHelp({ onBack }: { onBack: () => void }) {
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Import photos into your collection from folders or upload files</p>
<p className="text-gray-700">Import photos into your collection from folders. Choose between scanning from your local computer or from network paths.</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Scan Modes</h3>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Local:</p>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Select folders from your local computer using the browser</li>
<li>Works with File System Access API (Chrome, Edge, Safari) or webkitdirectory (Firefox)</li>
<li>The browser reads files and uploads them to the server</li>
<li>No server-side filesystem access needed</li>
<li>Perfect for scanning folders on your local machine</li>
</ul>
</div>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Network:</p>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Scan folders on network shares (UNC paths, mounted NFS/SMB shares)</li>
<li>Type the network path directly or use "Browse Network" to navigate</li>
<li>The server accesses the filesystem directly</li>
<li>Requires the backend server to have access to the network path</li>
<li>Perfect for scanning folders on network drives or mounted shares</li>
</ul>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Folder Selection:</strong> Browse and select folders containing photos</li>
<li><strong>Scan Mode Selection:</strong> Choose between "Scan from Local" or "Scan from Network"</li>
<li><strong>Local Folder Selection:</strong> Use browser's folder picker to select folders from your computer</li>
<li><strong>Network Path Input:</strong> Type network paths directly or browse network shares</li>
<li><strong>Recursive Scanning:</strong> Option to scan subdirectories recursively (enabled by default)</li>
<li><strong>Duplicate Detection:</strong> Automatically detects and skips duplicate photos</li>
<li><strong>Real-time Progress:</strong> Live progress tracking during import</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<p className="text-gray-700 font-medium mb-2">Folder Scan:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Click "Browse Folder" button</li>
<li>Select a folder containing photos</li>
<li>Toggle "Subdirectories recursively" if you want to include subfolders (enabled by default)</li>
<li>Click "Start Scan" button</li>
<li>Monitor progress in the progress bar</li>
<li>View results (photos added, existing photos skipped)</li>
</ol>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Local:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Select "Scan from Local" radio button</li>
<li>Click "Select Folder" button</li>
<li>Choose a folder from your local computer using the folder picker</li>
<li>The selected folder name will appear in the input field</li>
<li>Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)</li>
<li>Click "Start Scanning" button to begin the upload</li>
<li>Monitor progress in the progress bar</li>
<li>View results (photos added, existing photos skipped)</li>
</ol>
</div>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Scan from Network:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Select "Scan from Network" radio button</li>
<li>Either:
<ul className="list-disc list-inside ml-4 mt-1">
<li>Type the network path directly (e.g., <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">\\server\share</code> or <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">/mnt/nfs-share</code>)</li>
<li>Or click "Browse Network" to navigate network shares visually</li>
</ul>
</li>
<li>Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)</li>
<li>Click "Start Scanning" button</li>
<li>Monitor progress in the progress bar</li>
<li>View results (photos added, existing photos skipped)</li>
</ol>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">What Happens</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Local Mode:</strong> Browser reads files from your computer and uploads them to the server via HTTP</li>
<li><strong>Network Mode:</strong> Server accesses files directly from the network path</li>
<li>Photos are added to database</li>
<li>Duplicate photos are automatically skipped</li>
<li>Faces are NOT detected yet (use Process page for that)</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Use "Scan from Local" for folders on your computer - works in all modern browsers</li>
<li>Use "Scan from Network" for folders on network drives or mounted shares</li>
<li>Recursive scanning is enabled by default - uncheck if you only want the top-level folder</li>
<li>Large folders may take time to scan - be patient and monitor the progress</li>
<li>Duplicate detection prevents adding the same photo twice</li>
<li>After scanning, use the Process page to detect faces in the imported photos</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
@ -418,7 +474,7 @@ function AutoMatchPageHelp({ onBack }: { onBack: () => void }) {
<li>Click "🚀 Run Auto-Match" button</li>
<li>The system will automatically match unidentified faces to identified people based on:
<ul className="list-disc list-inside ml-4 mt-1">
<li>Similarity higher than 70%</li>
<li>Similarity higher than 85%</li>
<li>Picture quality higher than 50%</li>
<li>Profile faces are excluded for better accuracy</li>
</ul>
@ -616,7 +672,7 @@ function ModifyPageHelp({ onBack }: { onBack: () => void }) {
<p className="text-gray-700 font-medium mb-2">Finding and Selecting a Person:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Modify page</li>
<li>Optionally search for a person by entering their last name or maiden name in the search box</li>
<li>Optionally search for a person by entering their first, middle, last, or maiden name in the search box</li>
<li>Click "Search" to filter the list, or "Clear" to show all people</li>
<li>Click on a person's name in the left panel to select them</li>
<li>The person's faces and videos will load in the right panels</li>

View File

@ -5,6 +5,7 @@ import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
import tagsApi, { TagResponse } from '../api/tags'
import videosApi, { VideoListItem, VideoPersonInfo, IdentifyVideoRequest } from '../api/videos'
import { WebPlaybackVideo } from '../components/WebPlaybackVideo'
import { useDeveloperMode } from '../context/DeveloperModeContext'
import { useAuth } from '../context/AuthContext'
import pendingIdentificationsApi, {
@ -348,7 +349,8 @@ export default function Identify() {
return
}
try {
const res = await facesApi.getSimilar(faceId, includeExcludedFaces)
// Enable debug mode to log encoding info to browser console
const res = await facesApi.getSimilar(faceId, includeExcludedFaces, true)
setSimilar(res.items || [])
setSelectedSimilar({})
} catch (error) {
@ -386,7 +388,7 @@ export default function Identify() {
} finally {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoIds])
// Load state from sessionStorage on mount (faces, current index, similar, form data)
@ -433,7 +435,7 @@ export default function Identify() {
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoIds])
// Save state to sessionStorage whenever it changes (but only after initial restore)
@ -530,7 +532,7 @@ export default function Identify() {
loadPeople()
loadTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded])
// Reset filters when photoIds is provided (to ensure all faces from those photos are shown)
@ -544,7 +546,7 @@ export default function Identify() {
// Keep uniqueFacesOnly as is (user preference)
// Keep sortBy/sortDir as defaults (quality desc)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoIds, settingsLoaded])
// Initial load on mount (after settings and state are loaded)
@ -604,7 +606,8 @@ export default function Identify() {
const preloadImages = () => {
const preloadUrls: string[] = []
const baseUrl = apiClient.defaults.baseURL || 'http://127.0.0.1:8000'
// Use relative path when baseURL is empty (works with proxy and HTTPS)
const baseUrl = apiClient.defaults.baseURL || ''
// Preload next face
if (currentIdx + 1 < faces.length) {
@ -951,6 +954,7 @@ export default function Identify() {
loadVideos()
loadPeople() // Load people for the dropdown
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, videosPage, videosPageSize, videosFolderFilter, videosDateFrom, videosDateTo, videosHasPeople, videosPersonName, videosSortBy, videosSortDir])
return (
@ -1290,7 +1294,6 @@ export default function Identify() {
crossOrigin="anonymous"
loading="eager"
onLoad={() => setImageLoading(false)}
onLoadStart={() => setImageLoading(true)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
@ -1940,8 +1943,9 @@ export default function Identify() {
<h3 className="text-lg font-semibold text-gray-700 mb-2">
{selectedVideo.filename}
</h3>
<video
src={videosApi.getVideoUrl(selectedVideo.id)}
<WebPlaybackVideo
videoId={selectedVideo.id}
mediaPath={selectedVideo.path}
controls
className="w-full max-h-64 rounded"
/>

View File

@ -621,7 +621,10 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
const filteredUsers = useMemo(() => {
// Hide the special system user used for frontend approvals
const visibleUsers = users.filter((user) => user.username !== 'FrontEndUser')
// Also hide the default admin user
const visibleUsers = users.filter(
(user) => user.username !== 'FrontEndUser' && user.username?.toLowerCase() !== 'admin'
)
if (filterRole === null) {
return visibleUsers
@ -647,7 +650,10 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
}, [filteredUsers, userSort])
const filteredAuthUsers = useMemo(() => {
let filtered = [...authUsers]
// Hide the default admin user (admin@admin.com)
let filtered = authUsers.filter(
(user) => user.email?.toLowerCase() !== 'admin@admin.com'
)
// Filter by active status
if (authFilterActive !== null) {

View File

@ -2,6 +2,8 @@ import { useEffect, useState, useRef, useCallback } from 'react'
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people'
import facesApi from '../api/faces'
import videosApi from '../api/videos'
import { WebPlaybackVideo } from '../components/WebPlaybackVideo'
import { apiClient } from '../api/client'
interface EditDialogProps {
person: PersonWithFaces
@ -146,7 +148,7 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
export default function Modify() {
const [people, setPeople] = useState<PersonWithFaces[]>([])
const [lastNameFilter, setLastNameFilter] = useState('')
const [nameFilter, setNameFilter] = useState('')
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null)
const [selectedPersonName, setSelectedPersonName] = useState('')
const [faces, setFaces] = useState<PersonFaceItem[]>([])
@ -186,7 +188,7 @@ export default function Modify() {
try {
setBusy(true)
setError(null)
const res = await peopleApi.listWithFaces(lastNameFilter || undefined)
const res = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(res.items)
// Auto-select first person if available and none selected (only if not restoring state)
@ -202,7 +204,7 @@ export default function Modify() {
} finally {
setBusy(false)
}
}, [lastNameFilter, selectedPersonId])
}, [nameFilter, selectedPersonId])
// Load faces for a person
const loadPersonFaces = useCallback(async (personId: number) => {
@ -247,12 +249,15 @@ export default function Modify() {
useEffect(() => {
let restoredPanelWidth = false
try {
const saved = sessionStorage.getItem(STATE_KEY)
if (saved) {
const state = JSON.parse(saved)
if (state.lastNameFilter !== undefined) {
setLastNameFilter(state.lastNameFilter || '')
}
const saved = sessionStorage.getItem(STATE_KEY)
if (saved) {
const state = JSON.parse(saved)
if (state.nameFilter !== undefined) {
setNameFilter(state.nameFilter || '')
} else if (state.lastNameFilter !== undefined) {
// Backward compatibility with old state key
setNameFilter(state.lastNameFilter || '')
}
if (state.selectedPersonId !== undefined && state.selectedPersonId !== null) {
setSelectedPersonId(state.selectedPersonId)
}
@ -305,7 +310,7 @@ export default function Modify() {
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
@ -364,7 +369,7 @@ export default function Modify() {
try {
const state = {
lastNameFilter,
nameFilter,
selectedPersonId,
selectedPersonName,
faces,
@ -379,10 +384,10 @@ export default function Modify() {
} catch (error) {
console.error('Error saving state to sessionStorage:', error)
}
}, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored])
}, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored])
// Save state on unmount (when navigating away) - use refs to capture latest values
const lastNameFilterRef = useRef(lastNameFilter)
const nameFilterRef = useRef(nameFilter)
const selectedPersonIdRef = useRef(selectedPersonId)
const selectedPersonNameRef = useRef(selectedPersonName)
const facesRef = useRef(faces)
@ -395,7 +400,7 @@ export default function Modify() {
// Update refs whenever state changes
useEffect(() => {
lastNameFilterRef.current = lastNameFilter
nameFilterRef.current = nameFilter
selectedPersonIdRef.current = selectedPersonId
selectedPersonNameRef.current = selectedPersonName
facesRef.current = faces
@ -405,14 +410,14 @@ export default function Modify() {
facesExpandedRef.current = facesExpanded
videosExpandedRef.current = videosExpanded
peoplePanelWidthRef.current = peoplePanelWidth
}, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth])
}, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth])
// Save state on unmount (when navigating away)
useEffect(() => {
return () => {
try {
const state = {
lastNameFilter: lastNameFilterRef.current,
nameFilter: nameFilterRef.current,
selectedPersonId: selectedPersonIdRef.current,
selectedPersonName: selectedPersonNameRef.current,
faces: facesRef.current,
@ -462,7 +467,7 @@ export default function Modify() {
}
const handleClearSearch = () => {
setLastNameFilter('')
setNameFilter('')
// loadPeople will be called by useEffect
}
@ -547,6 +552,33 @@ export default function Modify() {
})
}
const confirmUnmatchFace = async () => {
if (!unmatchConfirmDialog || !selectedPersonId || !unmatchConfirmDialog.faceId) return
try {
setBusy(true)
setError(null)
setUnmatchConfirmDialog(null)
// Unmatch the single face
await facesApi.batchUnmatch({ face_ids: [unmatchConfirmDialog.faceId] })
// Reload people list to update face counts
const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload faces
await loadPersonFaces(selectedPersonId)
setSuccess('Successfully unlinked face')
setTimeout(() => setSuccess(null), 3000)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to unmatch face')
} finally {
setBusy(false)
}
}
const confirmBulkUnmatchFaces = async () => {
if (!unmatchConfirmDialog || !selectedPersonId || selectedFaces.size === 0) return
@ -563,7 +595,7 @@ export default function Modify() {
setSelectedFaces(new Set())
// Reload people list to update face counts
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload faces
@ -599,7 +631,7 @@ export default function Modify() {
await videosApi.removePerson(unmatchConfirmDialog.videoId, selectedPersonId)
// Reload people list
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload videos
@ -651,7 +683,7 @@ export default function Modify() {
setSelectedVideos(new Set())
// Reload people list
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload videos
@ -692,10 +724,10 @@ export default function Modify() {
<div className="flex gap-2 mb-1">
<input
type="text"
value={lastNameFilter}
onChange={(e) => setLastNameFilter(e.target.value)}
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Type Last Name or Maiden Name"
placeholder="Type First, Middle, Last, or Maiden Name"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
@ -711,7 +743,7 @@ export default function Modify() {
Clear
</button>
</div>
<p className="text-xs text-gray-500">Search by Last Name or Maiden Name</p>
<p className="text-xs text-gray-500">Search by First, Middle, Last, or Maiden Name</p>
</div>
{/* People list */}
@ -852,12 +884,12 @@ export default function Modify() {
<div key={face.id} className="flex flex-col items-center">
<div className="w-20 h-20 mb-2">
<img
src={`/api/v1/faces/${face.id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${face.id}/crop`}
alt={`Face ${face.id}`}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => {
// Open photo in new window
window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
window.open(`${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`, '_blank')
}}
title="Click to show original photo"
onError={(e) => {
@ -1114,14 +1146,13 @@ export default function Modify() {
×
</button>
</div>
<video
src={videosApi.getVideoUrl(selectedVideoToPlay.id)}
<WebPlaybackVideo
videoId={selectedVideoToPlay.id}
mediaPath={selectedVideoToPlay.path}
controls
autoPlay
className="w-full max-h-[80vh] rounded"
>
Your browser does not support the video tag.
</video>
/>
{selectedVideoToPlay.date_taken && (
<div className="text-sm text-gray-300 mt-2">
Date taken: {new Date(selectedVideoToPlay.date_taken).toLocaleDateString()}

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { pendingPhotosApi, PendingPhotoResponse, ReviewDecision, CleanupResponse } from '../api/pendingPhotos'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
// Removed unused videosApi import
type SortKey = 'photo' | 'uploaded_by' | 'file_info' | 'submitted_at' | 'status'
@ -259,7 +259,7 @@ export default function PendingPhotos() {
// Apply to all currently rejected photos
const rejectedPhotoIds = Object.entries(decisions)
.filter(([id, decision]) => decision === 'reject')
.filter(([_id, decision]) => decision === 'reject')
.map(([id]) => parseInt(id))
if (rejectedPhotoIds.length > 0) {

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, useRef } from 'react'
import {
reportedPhotosApi,
ReportedPhotoResponse,
@ -18,6 +18,8 @@ export default function ReportedPhotos() {
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
const imageUrlsRef = useRef<Record<number, string>>({})
const loadReportedPhotos = useCallback(async () => {
setLoading(true)
@ -36,6 +38,19 @@ export default function ReportedPhotos() {
}
})
setReviewNotes(existingNotes)
// Create direct backend URLs for images (only for non-video photos)
const newImageUrls: Record<number, string> = {}
// Use relative path when baseURL is empty (works with proxy and HTTPS)
const baseURL = apiClient.defaults.baseURL || ''
response.items.forEach((reported) => {
if (reported.photo_id && reported.photo_media_type !== 'video') {
// Use direct backend URL - the backend endpoint doesn't require auth for images
newImageUrls[reported.photo_id] = `${baseURL}/api/v1/photos/${reported.photo_id}/image`
}
})
setImageUrls(newImageUrls)
imageUrlsRef.current = newImageUrls
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load reported photos')
console.error('Error loading reported photos:', err)
@ -43,6 +58,15 @@ export default function ReportedPhotos() {
setLoading(false)
}
}, [statusFilter])
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
Object.values(imageUrlsRef.current).forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])
useEffect(() => {
loadReportedPhotos()
@ -339,7 +363,7 @@ export default function ReportedPhotos() {
onClick={() => {
const isVideo = reported.photo_media_type === 'video'
const url = isVideo
? videosApi.getVideoUrl(reported.photo_id)
? `/video/${reported.photo_id}`
: `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image`
window.open(url, '_blank')
}}
@ -364,9 +388,10 @@ export default function ReportedPhotos() {
}
}}
/>
) : (
) : imageUrls[reported.photo_id] ? (
<img
src={`/api/v1/photos/${reported.photo_id}/image`}
key={`photo-${reported.photo_id}-${imageUrls[reported.photo_id]}`}
src={imageUrls[reported.photo_id]}
alt={`Photo ${reported.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
@ -383,6 +408,10 @@ export default function ReportedPhotos() {
}
}}
/>
) : (
<div className="w-24 h-24 bg-gray-200 rounded border border-gray-300 flex items-center justify-center text-xs text-gray-400">
Loading...
</div>
)}
</div>
) : (

View File

@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { photosApi, PhotoImportRequest } from '../api/photos'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
import FolderBrowser from '../components/FolderBrowser'
interface JobProgress {
id: string
@ -11,11 +12,70 @@ interface JobProgress {
total?: number
}
type ScanMode = 'network' | 'local'
// Supported image and video extensions for File System Access API
const SUPPORTED_EXTENSIONS = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif',
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.flv', '.wmv'
]
// Check if File System Access API is supported
const isFileSystemAccessSupported = (): boolean => {
return 'showDirectoryPicker' in window
}
// Check if webkitdirectory (fallback) is supported
const isWebkitDirectorySupported = (): boolean => {
const input = document.createElement('input')
return 'webkitdirectory' in input
}
// Check if running on iOS
const isIOS = (): boolean => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
// Recursively read all files from a directory handle
async function readDirectoryRecursive(
dirHandle: FileSystemDirectoryHandle,
recursive: boolean = true
): Promise<File[]> {
const files: File[] = []
async function traverse(handle: FileSystemDirectoryHandle, path: string = '') {
// @ts-ignore - File System Access API types may not be available
for await (const entry of handle.values()) {
if (entry.kind === 'file') {
const file = await entry.getFile()
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (SUPPORTED_EXTENSIONS.includes(ext)) {
files.push(file)
}
} else if (entry.kind === 'directory' && recursive) {
await traverse(entry, path + '/' + entry.name)
}
}
}
await traverse(dirHandle)
return files
}
export default function Scan() {
const [scanMode, setScanMode] = useState<ScanMode>('local')
const [folderPath, setFolderPath] = useState('')
const [recursive, setRecursive] = useState(true)
const [isImporting, setIsImporting] = useState(false)
const [isBrowsing, setIsBrowsing] = useState(false)
const [showFolderBrowser, setShowFolderBrowser] = useState(false)
const [localUploadProgress, setLocalUploadProgress] = useState<{
current: number
total: number
filename: string
} | null>(null)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement | null>(null)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
const [importResult, setImportResult] = useState<{
@ -35,189 +95,196 @@ export default function Scan() {
}
}, [])
const handleFolderBrowse = async () => {
setIsBrowsing(true)
const handleFolderBrowse = () => {
setError(null)
// Try backend API first (uses tkinter for native folder picker with full path)
try {
console.log('Attempting to open native folder picker...')
const result = await photosApi.browseFolder()
console.log('Backend folder picker result:', result)
if (result.success && result.path) {
// Ensure we have a valid absolute path (not just folder name)
const path = result.path.trim()
if (path && path.length > 0) {
// Verify it looks like an absolute path:
// - Unix/Linux: starts with / (includes mounted network shares like /mnt/...)
// - Windows local: starts with drive letter like C:\
// - Windows UNC: starts with \\ (network paths like \\server\share\folder)
const isUnixPath = path.startsWith('/')
const isWindowsLocalPath = /^[A-Za-z]:[\\/]/.test(path)
const isWindowsUncPath = path.startsWith('\\\\') || path.startsWith('//')
if (isUnixPath || isWindowsLocalPath || isWindowsUncPath) {
setFolderPath(path)
setIsBrowsing(false)
return
} else {
// Backend validated it, so trust it even if it doesn't match our patterns
// (might be a valid path format we didn't account for)
console.warn('Backend returned path with unexpected format:', path)
setFolderPath(path)
setIsBrowsing(false)
return
}
}
}
// If we get here, result.success was false or path was empty
console.warn('Backend folder picker returned no path:', result)
if (result.success === false && result.message) {
setError(result.message || 'No folder was selected. Please try again.')
} else {
setError('No folder was selected. Please try again.')
}
setIsBrowsing(false)
} catch (err: any) {
// Backend API failed, fall back to browser picker
console.warn('Backend folder picker unavailable, using browser fallback:', err)
// Extract error message from various possible locations
const errorMsg = err?.response?.data?.detail ||
err?.response?.data?.message ||
err?.message ||
String(err) ||
''
console.log('Error details:', {
status: err?.response?.status,
detail: err?.response?.data?.detail,
message: err?.message,
fullError: err
})
// Check if it's a display/availability issue
if (errorMsg.includes('display') || errorMsg.includes('DISPLAY') || errorMsg.includes('tkinter')) {
// Show user-friendly message about display issue
setError('Native folder picker unavailable. Using browser fallback.')
} else if (err?.response?.status === 503) {
// 503 Service Unavailable - likely tkinter or display issue
setError('Native folder picker unavailable (tkinter/display issue). Using browser fallback.')
} else {
// Other error - log it but continue to browser fallback
console.error('Error calling backend folder picker:', err)
setError('Native folder picker unavailable. Using browser fallback.')
}
}
// Fallback: Use browser-based folder picker
// This code runs if backend API failed or returned no path
console.log('Attempting browser fallback folder picker...')
// Use File System Access API if available (modern browsers)
if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
try {
console.log('Using File System Access API...')
const directoryHandle = await (window as any).showDirectoryPicker()
// Get the folder name from the handle
const folderName = directoryHandle.name
// Note: Browsers don't expose full absolute paths for security reasons
console.log('Selected folder name:', folderName)
// Browser picker only gives folder name, not full path
// Set the folder name and show helpful message
setFolderPath(folderName)
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
} catch (err: any) {
// User cancelled the picker
if (err.name !== 'AbortError') {
console.error('Error selecting folder:', err)
setError('Error opening folder picker: ' + err.message)
} else {
// User cancelled - clear any previous error
setError(null)
}
} finally {
setIsBrowsing(false)
}
} else {
// Fallback: use a hidden directory input
// Note: This will show a browser confirmation dialog that cannot be removed
console.log('Using file input fallback...')
const input = document.createElement('input')
input.type = 'file'
input.setAttribute('webkitdirectory', '')
input.setAttribute('directory', '')
input.setAttribute('multiple', '')
input.style.display = 'none'
input.onchange = (e: any) => {
const files = e.target.files
if (files && files.length > 0) {
const firstFile = files[0]
const relativePath = firstFile.webkitRelativePath
const pathParts = relativePath.split('/')
const rootFolder = pathParts[0]
// Note: Browsers don't expose full absolute paths for security reasons
console.log('Selected folder name:', rootFolder)
// Browser picker only gives folder name, not full path
// Set the folder name and show helpful message
setFolderPath(rootFolder)
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
}
if (document.body.contains(input)) {
document.body.removeChild(input)
}
setIsBrowsing(false)
}
input.oncancel = () => {
if (document.body.contains(input)) {
document.body.removeChild(input)
}
setIsBrowsing(false)
}
document.body.appendChild(input)
input.click()
}
setShowFolderBrowser(true)
}
const handleScanFolder = async () => {
if (!folderPath.trim()) {
setError('Please enter a folder path')
const handleFolderSelect = (selectedPath: string) => {
setFolderPath(selectedPath)
setError(null)
}
const handleLocalFolderSelect = (files: FileList | null) => {
if (!files || files.length === 0) {
return
}
setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
setLocalUploadProgress(null)
// Filter to only supported files
const fileArray = Array.from(files).filter((file) => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
return SUPPORTED_EXTENSIONS.includes(ext)
})
if (fileArray.length === 0) {
setError('No supported image or video files found in the selected folder.')
setSelectedFiles([])
return
}
// Set folder path from first file's path
if (fileArray.length > 0) {
const firstFile = fileArray[0]
// Extract folder path from file path (webkitdirectory includes full path)
// On iOS, webkitRelativePath may not be available, so use a generic label
if (firstFile.webkitRelativePath) {
const folderPath = firstFile.webkitRelativePath.split('/').slice(0, -1).join('/')
setFolderPath(folderPath || 'Selected folder')
} else {
// iOS Photos selection - no folder path available
setFolderPath(`Selected ${fileArray.length} file${fileArray.length > 1 ? 's' : ''} from Photos`)
}
}
// Store files for later upload
setSelectedFiles(fileArray)
}
const handleStartLocalScan = async () => {
if (selectedFiles.length === 0) {
setError('Please select a folder first.')
return
}
try {
const request: PhotoImportRequest = {
folder_path: folderPath.trim(),
recursive,
setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
setLocalUploadProgress(null)
// Upload files to backend in batches to show progress
setLocalUploadProgress({ current: 0, total: selectedFiles.length, filename: '' })
// Upload files in batches to show progress (increased from 10 to 25 for better performance)
const batchSize = 25
let uploaded = 0
let totalAdded = 0
let totalExisting = 0
for (let i = 0; i < selectedFiles.length; i += batchSize) {
const batch = selectedFiles.slice(i, i + batchSize)
const response = await photosApi.uploadPhotos(batch)
uploaded += batch.length
totalAdded += response.added || 0
totalExisting += response.existing || 0
setLocalUploadProgress({
current: uploaded,
total: selectedFiles.length,
filename: batch[batch.length - 1]?.name || '',
})
}
setImportResult({
added: totalAdded,
existing: totalExisting,
total: selectedFiles.length,
})
setIsImporting(false)
setLocalUploadProgress(null)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to upload files')
setIsImporting(false)
setLocalUploadProgress(null)
}
}
const handleScanFolder = async () => {
if (scanMode === 'local') {
// For local mode, use File System Access API if available, otherwise fallback to webkitdirectory
if (isFileSystemAccessSupported()) {
// Use File System Access API (Chrome, Edge, Safari)
try {
setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
setLocalUploadProgress(null)
// Show directory picker
// @ts-ignore - File System Access API types may not be available
const dirHandle = await window.showDirectoryPicker()
const folderName = dirHandle.name
setFolderPath(folderName)
// Read all files from the directory
const files = await readDirectoryRecursive(dirHandle, recursive)
if (files.length === 0) {
setError('No supported image or video files found in the selected folder.')
setSelectedFiles([])
setIsImporting(false)
return
}
// For File System Access API, files are File objects with lastModified
// Store files with their metadata for later upload
setSelectedFiles(files)
setIsImporting(false)
} catch (err: any) {
if (err.name === 'AbortError') {
// User cancelled the folder picker
setError(null)
setSelectedFiles([])
setIsImporting(false)
} else {
setError(err.message || 'Failed to select folder')
setSelectedFiles([])
setIsImporting(false)
}
}
} else if (isWebkitDirectorySupported()) {
// Fallback: Use webkitdirectory input (Firefox, older browsers)
fileInputRef.current?.click()
} else {
setError('Folder selection is not supported in your browser. Please use Chrome, Edge, Safari, or Firefox.')
}
} else {
// For network mode, use the existing path-based import
if (!folderPath.trim()) {
setError('Please enter a folder path')
return
}
const response = await photosApi.importPhotos(request)
setCurrentJob({
id: response.job_id,
status: JobStatus.PENDING,
progress: 0,
message: response.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
// Start SSE stream for job progress
startJobProgressStream(response.job_id)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Import failed')
setIsImporting(false)
try {
const request: PhotoImportRequest = {
folder_path: folderPath.trim(),
recursive,
}
const response = await photosApi.importPhotos(request)
setCurrentJob({
id: response.job_id,
status: JobStatus.PENDING,
progress: 0,
message: response.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Start SSE stream for job progress
startJobProgressStream(response.job_id)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Import failed')
setIsImporting(false)
}
}
}
@ -271,9 +338,22 @@ export default function Scan() {
eventSource.onerror = (err) => {
console.error('SSE error:', err)
// Check if connection failed (readyState 0 = CONNECTING, 2 = CLOSED)
if (eventSource.readyState === EventSource.CLOSED) {
setError('Connection to server lost. The job may still be running. Please refresh the page to check status.')
setIsImporting(false)
} else if (eventSource.readyState === EventSource.CONNECTING) {
// Still connecting, don't show error yet
console.log('SSE still connecting...')
}
eventSource.close()
eventSourceRef.current = null
}
// Handle connection open
eventSource.onopen = () => {
console.log('SSE connection opened for job:', jobId)
}
}
const fetchJobResult = async (jobId: string) => {
@ -312,34 +392,139 @@ export default function Scan() {
</h2>
<div className="space-y-4">
{/* Scan Mode Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Scan Mode
</label>
<div className="flex gap-6">
<label className="flex items-center">
<input
type="radio"
name="scan-mode"
value="local"
checked={scanMode === 'local'}
onChange={() => {
setScanMode('local')
setFolderPath('')
setSelectedFiles([])
setError(null)
}}
disabled={isImporting}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-700">Scan from Local</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="scan-mode"
value="network"
checked={scanMode === 'network'}
onChange={() => {
setScanMode('network')
setFolderPath('')
setSelectedFiles([])
setError(null)
}}
disabled={isImporting}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-700">Scan from Network</span>
</label>
</div>
</div>
<div>
<label
htmlFor="folder-path"
className="block text-sm font-medium text-gray-700 mb-2"
>
Folder Path
{scanMode === 'local' ? 'Selected Folder' : 'Folder or File Path'}
</label>
<div className="flex gap-2">
<input
id="folder-path"
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/path/to/photos"
className="w-1/2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isImporting}
/>
<button
type="button"
onClick={handleFolderBrowse}
disabled={isImporting || isBrowsing}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isBrowsing ? 'Opening...' : 'Browse'}
</button>
{scanMode === 'local' ? (
<>
<input
id="folder-path"
type="text"
value={folderPath || 'No folder selected'}
readOnly
className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 text-gray-600"
disabled={isImporting}
/>
<input
ref={fileInputRef}
type="file"
{...(isIOS()
? {
accept: 'image/*,video/*',
multiple: true
}
: {
webkitdirectory: '',
directory: '',
multiple: true
} as any)}
style={{ display: 'none' }}
onChange={(e) => handleLocalFolderSelect(e.target.files)}
/>
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || (!isFileSystemAccessSupported() && !isWebkitDirectorySupported() && !isIOS())}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title={isIOS() ? "Select photos and videos from Photos app" : "Select folder from your local computer"}
>
{isImporting ? 'Scanning...' : 'Select Folder'}
</button>
</>
) : (
<>
<input
id="folder-path"
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="Type network path: \\\\server\\share or /mnt/nfs-share"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isImporting}
/>
<button
type="button"
onClick={handleFolderBrowse}
disabled={isImporting}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Browse network paths (UNC paths, mounted shares)"
>
Browse Network
</button>
</>
)}
</div>
<p className="mt-1 text-sm text-gray-500">
Enter the full absolute path to the folder containing photos / videos.
{scanMode === 'local' ? (
<>
{isIOS() ? (
<>
Click "Select Folder" to choose photos and videos from your Photos app. You can select multiple files at once.
</>
) : (
<>
Click "Select Folder" to choose a folder from your local computer. The browser will read the files and upload them to the server.
</>
)}
{!isFileSystemAccessSupported() && !isWebkitDirectorySupported() && !isIOS() && (
<span className="text-orange-600 block mt-1">
Folder selection is not supported in your browser. Please use Chrome, Edge, Safari, or Firefox.
</span>
)}
</>
) : (
<>
Type a network folder path directly (e.g., <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">\\server\share</code> or <code className="text-xs bg-gray-100 px-1 py-0.5 rounded">/mnt/nfs-share</code>), or click "Browse Network" to navigate network shares.
</>
)}
</p>
</div>
@ -360,17 +545,61 @@ export default function Scan() {
</label>
</div>
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
{scanMode === 'local' && (
<button
type="button"
onClick={handleStartLocalScan}
disabled={isImporting || selectedFiles.length === 0}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
)}
{scanMode === 'network' && (
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
)}
</div>
</div>
{/* Local Upload Progress Section */}
{localUploadProgress && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Upload Progress
</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-blue-600">
Uploading files...
</span>
<span className="text-sm text-gray-600">
{localUploadProgress.current} / {localUploadProgress.total}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${(localUploadProgress.current / localUploadProgress.total) * 100}%` }}
/>
</div>
</div>
{localUploadProgress.filename && (
<div className="text-sm text-gray-600">
<p>Current file: {localUploadProgress.filename}</p>
</div>
)}
</div>
</div>
)}
{/* Progress Section */}
{(currentJob || jobProgress) && (
<div className="bg-white rounded-lg shadow p-6">
@ -455,6 +684,15 @@ export default function Scan() {
</div>
)}
</div>
{/* Folder Browser Modal */}
{showFolderBrowser && (
<FolderBrowser
onSelectPath={handleFolderSelect}
initialPath={folderPath || '/'}
onClose={() => setShowFolderBrowser(false)}
/>
)}
</div>
)
}

View File

@ -309,7 +309,9 @@ export default function Search() {
} catch (error: any) {
console.error('Error searching photos:', error)
const errorMessage = error.response?.data?.detail || error.message || 'Unknown error'
alert(`Error searching photos: ${errorMessage}\n\nPlease check:\n1. Backend API is running (http://127.0.0.1:8000)\n2. You are logged in\n3. Database connection is working`)
alert(
`Error searching photos: ${errorMessage}\n\nPlease check:\n1. Backend API is running\n2. You are logged in\n3. Database connection is working`
)
} finally {
setLoading(false)
}
@ -680,9 +682,17 @@ export default function Search() {
.join(', ')
}, [selectedTagIds, allPhotoTags])
const openPhoto = (photoId: number) => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
window.open(photoUrl, '_blank')
const openPhoto = (photoId: number, mediaType?: string) => {
const isVideo = mediaType === 'video'
if (isVideo) {
// Open video in VideoPlayer page with Play button
const videoPlayerUrl = `/video/${photoId}`
window.open(videoPlayerUrl, '_blank')
} else {
// Use image endpoint for images
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
window.open(photoUrl, '_blank')
}
}
const openFolder = async (photoId: number) => {
@ -1784,9 +1794,9 @@ export default function Search() {
)}
<td className="p-2">
<button
onClick={() => openPhoto(photo.id)}
onClick={() => openPhoto(photo.id, photo.media_type)}
className="text-blue-600 hover:underline cursor-pointer"
title="Open photo"
title={photo.media_type === 'video' ? 'Open video' : 'Open photo'}
>
{photo.path}
</button>

View File

@ -1,7 +1,7 @@
import { useDeveloperMode } from '../context/DeveloperModeContext'
export default function Settings() {
const { isDeveloperMode, setDeveloperMode } = useDeveloperMode()
const { isDeveloperMode } = useDeveloperMode()
return (
<div>
@ -11,24 +11,23 @@ export default function Settings() {
<div className="flex items-center justify-between py-3 border-b border-gray-200">
<div className="flex-1">
<label htmlFor="developer-mode" className="text-sm font-medium text-gray-700">
<label className="text-sm font-medium text-gray-700">
Developer Mode
</label>
<p className="text-xs text-gray-500 mt-1">
Enable developer features. Additional features will be available when enabled.
{isDeveloperMode
? 'Developer mode is enabled (controlled by VITE_DEVELOPER_MODE environment variable)'
: 'Developer mode is disabled. Set VITE_DEVELOPER_MODE=true in .env to enable.'}
</p>
</div>
<div className="ml-4">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="developer-mode"
checked={isDeveloperMode}
onChange={(e) => setDeveloperMode(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
<div className={`px-3 py-1 rounded text-sm font-medium ${
isDeveloperMode
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{isDeveloperMode ? 'Enabled' : 'Disabled'}
</div>
</div>
</div>
</div>

View File

@ -1,18 +1,12 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags'
import { useDeveloperMode } from '../context/DeveloperModeContext'
import { apiClient } from '../api/client'
import videosApi from '../api/videos'
type ViewMode = 'list' | 'icons' | 'compact'
interface PendingTagChange {
photoId: number
tagIds: number[]
}
interface PendingTagRemoval {
photoId: number
tagIds: number[]
}
// Removed unused interfaces PendingTagChange and PendingTagRemoval
interface FolderGroup {
folderPath: string
@ -41,7 +35,7 @@ const loadFolderStatesFromStorage = (): Record<string, boolean> => {
}
export default function Tags() {
const { isDeveloperMode } = useDeveloperMode()
const { isDeveloperMode: _isDeveloperMode } = useDeveloperMode()
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [photos, setPhotos] = useState<PhotoWithTagsItem[]>([])
const [tags, setTags] = useState<TagResponse[]>([])
@ -50,7 +44,7 @@ export default function Tags() {
const [pendingTagChanges, setPendingTagChanges] = useState<Record<number, number[]>>({})
const [pendingTagRemovals, setPendingTagRemovals] = useState<Record<number, number[]>>({})
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [_saving, setSaving] = useState(false)
const [showManageTags, setShowManageTags] = useState(false)
const [showTagDialog, setShowTagDialog] = useState<number | null>(null)
const [showBulkTagDialog, setShowBulkTagDialog] = useState<string | null>(null)
@ -189,7 +183,7 @@ export default function Tags() {
aVal = a.face_count || 0
bVal = b.face_count || 0
break
case 'identified':
case 'identified': {
// Sort by identified count (identified/total ratio)
const aTotal = a.face_count || 0
const aIdentified = aTotal - (a.unidentified_face_count || 0)
@ -206,13 +200,15 @@ export default function Tags() {
bVal = bIdentified
}
break
case 'tags':
}
case 'tags': {
// Get tags for comparison - use photo.tags directly
const aTags = (a.tags || '').toLowerCase()
const bTags = (b.tags || '').toLowerCase()
aVal = aTags
bVal = bTags
break
}
default:
return 0
}
@ -420,8 +416,10 @@ export default function Tags() {
}
}
// Save pending changes
const saveChanges = async () => {
// Save pending changes (currently unused, kept for future use)
// @ts-expect-error - Intentionally unused, kept for future use
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _saveChanges = async () => {
const pendingPhotoIds = new Set([
...Object.keys(pendingTagChanges).map(Number),
...Object.keys(pendingTagRemovals).map(Number),
@ -489,8 +487,10 @@ export default function Tags() {
}
}
// Get pending changes count
const pendingChangesCount = useMemo(() => {
// Get pending changes count (currently unused, kept for future use)
// @ts-expect-error - Intentionally unused, kept for future use
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _pendingChangesCount = useMemo(() => {
const additions = Object.values(pendingTagChanges).reduce((sum, ids) => sum + ids.length, 0)
const removals = Object.values(pendingTagRemovals).reduce((sum, ids) => sum + ids.length, 0)
return additions + removals
@ -755,10 +755,19 @@ export default function Tags() {
<td className="p-2">{photo.id}</td>
<td className="p-2">
<a
href={`/api/v1/photos/${photo.id}/image`}
href={
photo.media_type === 'video'
? `/video/${photo.id}`
: `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image`
}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
title={
photo.media_type === 'video'
? 'Open video (browser-safe playback)'
: 'Open full photo'
}
>
{photo.filename}
</a>
@ -873,7 +882,12 @@ export default function Tags() {
{folderStates[folder.folderPath] === true && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{folder.photos.map(photo => {
const photoUrl = `/api/v1/photos/${photo.id}/image`
const isVideo = photo.media_type === 'video'
const imageUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image`
const thumbUrl = isVideo
? videosApi.getThumbnailUrl(photo.id)
: imageUrl
const openUrl = isVideo ? `/video/${photo.id}` : imageUrl
const isSelected = selectedPhotoIds.has(photo.id)
return (
@ -929,10 +943,13 @@ export default function Tags() {
{/* Thumbnail */}
<div className="w-full aspect-square bg-gray-100 overflow-hidden">
<img
src={photoUrl}
src={thumbUrl}
alt={photo.filename}
className="w-full h-full object-cover cursor-pointer"
onClick={() => window.open(photoUrl, '_blank')}
onClick={() => window.open(openUrl, '_blank')}
title={
isVideo ? 'Open video' : 'Open full photo'
}
onError={(e) => {
const target = e.target as HTMLImageElement
target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="150" height="150"%3E%3Crect fill="%23ddd" width="150" height="150"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3ENo Image%3C/text%3E%3C/svg%3E'
@ -1116,6 +1133,11 @@ export default function Tags() {
selectedPhotoIds={Array.from(selectedPhotoIds)}
photos={photos.filter(p => selectedPhotoIds.has(p.id))}
tags={tags}
onTagsUpdated={async () => {
// Reload tags when new tags are created
const tagsRes = await tagsApi.list()
setTags(tagsRes.items)
}}
onClose={async () => {
setShowTagSelectedDialog(false)
setSelectedPhotoIds(new Set())
@ -1399,6 +1421,7 @@ function PhotoTagDialog({
getPhotoTags: (photoId: number) => Promise<any>
}) {
const [selectedTagName, setSelectedTagName] = useState('')
const [newTagName, setNewTagName] = useState('')
const [photoTags, setPhotoTags] = useState<any[]>([])
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
@ -1417,10 +1440,36 @@ function PhotoTagDialog({
}
const handleAddTag = async () => {
if (!selectedTagName.trim()) return
await onAddTag(selectedTagName.trim())
setSelectedTagName('')
await loadPhotoTags()
// Collect both tags: selected existing tag and new tag name
const tagsToAdd: string[] = []
if (selectedTagName.trim()) {
tagsToAdd.push(selectedTagName.trim())
}
if (newTagName.trim()) {
tagsToAdd.push(newTagName.trim())
}
if (tagsToAdd.length === 0) {
alert('Please select a tag or enter a new tag name.')
return
}
try {
// Add all tags (onAddTag handles creating new tags if needed)
for (const tagName of tagsToAdd) {
await onAddTag(tagName)
}
// Clear inputs after successful tagging
setSelectedTagName('')
setNewTagName('')
await loadPhotoTags()
} catch (error) {
console.error('Failed to add tag:', error)
alert('Failed to add tag')
}
}
const handleRemoveTags = () => {
@ -1478,11 +1527,14 @@ function PhotoTagDialog({
{photo && <p className="text-sm text-gray-600">{photo.filename}</p>}
</div>
<div className="p-4 flex-1 overflow-auto">
<div className="mb-4 flex gap-2">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Existing Tag:
</label>
<select
value={selectedTagName}
onChange={(e) => setSelectedTagName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded"
className="w-full px-3 py-2 border border-gray-300 rounded"
>
<option value="">Select tag...</option>
{tags.map(tag => (
@ -1491,15 +1543,35 @@ function PhotoTagDialog({
</option>
))}
</select>
<button
onClick={handleAddTag}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add
</button>
<p className="text-xs text-gray-500 mt-1">
You can select an existing tag and enter a new tag name to add both at once.
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter New Tag Name:
</label>
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded"
placeholder="Type new tag name..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddTag()
}
}}
/>
<p className="text-xs text-gray-500 mt-1">
New tags will be created in the database automatically.
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-sm text-gray-700 mb-2">
Tags:
</h3>
{allTags.length === 0 ? (
<p className="text-gray-500 text-sm">No tags linked to this photo</p>
) : (
@ -1540,12 +1612,21 @@ function PhotoTagDialog({
>
Remove selected tags
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Close
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Close
</button>
<button
onClick={handleAddTag}
disabled={!selectedTagName.trim() && !newTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add Tag
</button>
</div>
</div>
</div>
</div>
@ -1555,7 +1636,7 @@ function PhotoTagDialog({
// Bulk Tag Dialog Component
function BulkTagDialog({
folderPath,
folderPath: _folderPath,
folder,
tags,
pendingTagChanges,
@ -1776,17 +1857,26 @@ function TagSelectedPhotosDialog({
selectedPhotoIds,
photos,
tags,
onTagsUpdated,
onClose,
}: {
selectedPhotoIds: number[]
photos: PhotoWithTagsItem[]
tags: TagResponse[]
onTagsUpdated?: () => Promise<void>
onClose: () => void
}) {
const [selectedTagName, setSelectedTagName] = useState('')
const [newTagName, setNewTagName] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [photoTagsData, setPhotoTagsData] = useState<Record<number, any[]>>({})
const [localTags, setLocalTags] = useState<TagResponse[]>(tags)
// Update local tags when tags prop changes
useEffect(() => {
setLocalTags(tags)
}, [tags])
// Load tag linkage information for all selected photos
useEffect(() => {
@ -1810,28 +1900,59 @@ function TagSelectedPhotosDialog({
}, [selectedPhotoIds])
const handleAddTag = async () => {
if (!selectedTagName.trim() || selectedPhotoIds.length === 0) return
if (selectedPhotoIds.length === 0) return
// Check if tag exists, create if not
let tag = tags.find(t => t.tag_name.toLowerCase() === selectedTagName.toLowerCase().trim())
if (!tag) {
try {
tag = await tagsApi.create(selectedTagName.trim())
// Note: We don't update the tags list here since it's passed from parent
} catch (error) {
console.error('Failed to create tag:', error)
alert('Failed to create tag')
return
}
// Collect both tags: selected existing tag and new tag name
const tagsToAdd: string[] = []
if (selectedTagName.trim()) {
tagsToAdd.push(selectedTagName.trim())
}
if (newTagName.trim()) {
tagsToAdd.push(newTagName.trim())
}
if (tagsToAdd.length === 0) {
alert('Please select a tag or enter a new tag name.')
return
}
// Make single batch API call for all selected photos
try {
// Create any new tags first
const newTags = tagsToAdd.filter(tag =>
!localTags.some(availableTag =>
availableTag.tag_name.toLowerCase() === tag.toLowerCase()
)
)
if (newTags.length > 0) {
const createdTags: TagResponse[] = []
for (const newTag of newTags) {
const createdTag = await tagsApi.create(newTag)
createdTags.push(createdTag)
}
// Update local tags immediately with newly created tags
setLocalTags(prev => {
const updated = [...prev, ...createdTags]
// Sort by tag name
return updated.sort((a, b) => a.tag_name.localeCompare(b.tag_name))
})
// Also reload tags list in parent to keep it in sync
if (onTagsUpdated) {
await onTagsUpdated()
}
}
// Add all tags to photos in a single API call
await tagsApi.addToPhotos({
photo_ids: selectedPhotoIds,
tag_names: [selectedTagName.trim()],
tag_names: tagsToAdd,
})
// Clear inputs after successful tagging
setSelectedTagName('')
setNewTagName('')
// Reload photo tags data to update the common tags list
const tagsData: Record<number, any[]> = {}
@ -1902,7 +2023,7 @@ function TagSelectedPhotosDialog({
allPhotoTags[photoId] = photoTagsData[photoId] || []
})
const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name]))
const tagIdToName = new Map(localTags.map(t => [t.id, t.tag_name]))
// Get all unique tag IDs from all photos
const allTagIds = new Set<number>()
@ -1931,7 +2052,7 @@ function TagSelectedPhotosDialog({
}
})
.filter(Boolean) as any[]
}, [photos, tags, selectedPhotoIds, photoTagsData])
}, [photos, localTags, selectedPhotoIds, photoTagsData])
// Get selected tag names for confirmation message
const selectedTagNames = useMemo(() => {
@ -1962,11 +2083,14 @@ function TagSelectedPhotosDialog({
</p>
</div>
<div className="p-4 flex-1 overflow-auto">
<div className="mb-4 flex gap-2">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Existing Tag:
</label>
<select
value={selectedTagName}
onChange={(e) => setSelectedTagName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded"
className="w-full px-3 py-2 border border-gray-300 rounded"
>
<option value="">Select tag...</option>
{tags.map(tag => (
@ -1975,13 +2099,29 @@ function TagSelectedPhotosDialog({
</option>
))}
</select>
<button
onClick={handleAddTag}
disabled={!selectedTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add
</button>
<p className="text-xs text-gray-500 mt-1">
You can select an existing tag and enter a new tag name to add both at once.
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter New Tag Name:
</label>
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded"
placeholder="Type new tag name..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddTag()
}
}}
/>
<p className="text-xs text-gray-500 mt-1">
New tags will be created in the database automatically.
</p>
</div>
<div className="space-y-2">
@ -2025,12 +2165,21 @@ function TagSelectedPhotosDialog({
>
Remove selected tags
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Close
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Close
</button>
<button
onClick={handleAddTag}
disabled={!selectedTagName.trim() && !newTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add Tag
</button>
</div>
</div>
</div>
</div>

View File

@ -443,7 +443,7 @@ export default function UserTaggedPhotos() {
onClick={() => {
const isVideo = linkage.photo_media_type === 'video'
const url = isVideo
? videosApi.getVideoUrl(linkage.photo_id)
? `/video/${linkage.photo_id}`
: `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image`
window.open(url, '_blank')
}}
@ -469,7 +469,7 @@ export default function UserTaggedPhotos() {
/>
) : (
<img
src={`/api/v1/photos/${linkage.photo_id}/image`}
src={`${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image`}
alt={`Photo ${linkage.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"

View File

@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { photosApi } from '../api/photos'
import { WebPlaybackVideo } from '../components/WebPlaybackVideo'
export default function VideoPlayer() {
const { id } = useParams<{ id: string }>()
const parsed = id ? parseInt(id, 10) : NaN
const videoId = Number.isFinite(parsed) ? parsed : null
const videoRef = useRef<HTMLVideoElement>(null)
const [showPlayButton, setShowPlayButton] = useState(true)
const [mediaPath, setMediaPath] = useState<string | null>(null)
const [pathError, setPathError] = useState<string | null>(null)
useEffect(() => {
if (videoId === null || Number.isNaN(videoId)) {
setMediaPath(null)
setPathError(null)
return
}
let cancelled = false
setMediaPath(null)
setPathError(null)
photosApi
.getPhoto(videoId)
.then((p) => {
if (!cancelled) {
setMediaPath(p.path)
}
})
.catch((e) => {
if (!cancelled) {
setPathError(
e?.response?.data?.detail || e?.message || 'Failed to load video'
)
setMediaPath('')
}
})
return () => {
cancelled = true
}
}, [videoId])
const handlePlay = () => {
if (videoRef.current) {
videoRef.current.play()
setShowPlayButton(false)
}
}
const handlePause = () => {
setShowPlayButton(true)
}
const handlePlayClick = () => {
handlePlay()
}
// Hide play button when video starts playing
useEffect(() => {
const video = videoRef.current
if (!video) return
const handlePlayEvent = () => {
setShowPlayButton(false)
}
const handlePauseEvent = () => {
setShowPlayButton(true)
}
const handleEnded = () => {
setShowPlayButton(true)
}
video.addEventListener('play', handlePlayEvent)
video.addEventListener('pause', handlePauseEvent)
video.addEventListener('ended', handleEnded)
return () => {
video.removeEventListener('play', handlePlayEvent)
video.removeEventListener('pause', handlePauseEvent)
video.removeEventListener('ended', handleEnded)
}
}, [])
if (videoId === null || Number.isNaN(videoId)) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="text-white text-xl">Video not found</div>
</div>
)
}
if (pathError && mediaPath === '') {
return (
<div className="min-h-screen flex items-center justify-center bg-black p-4">
<div className="text-red-200 text-center max-w-lg">{pathError}</div>
</div>
)
}
if (mediaPath === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="text-white text-xl">Loading</div>
</div>
)
}
return (
<div className="min-h-screen bg-black flex items-center justify-center p-4">
<div className="relative w-full max-w-full" style={{ maxHeight: 'calc(100vh - 2rem)' }}>
<WebPlaybackVideo
ref={videoRef}
videoId={videoId}
mediaPath={mediaPath}
className="w-full h-auto max-h-[calc(100vh-2rem)] object-contain"
controls={true}
controlsList="nodownload"
onPause={handlePause}
preload="metadata"
/>
{/* Play button overlay - centered, positioned above video controls */}
{showPlayButton && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div
className="absolute inset-0 bg-black bg-opacity-30 hover:bg-opacity-20 transition-all pointer-events-none"
/>
<button
className="relative z-10 w-20 h-20 bg-white bg-opacity-90 rounded-full flex items-center justify-center hover:bg-opacity-100 transition-all shadow-lg hover:scale-110 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black pointer-events-auto cursor-pointer"
aria-label="Play video"
onClick={handlePlayClick}
>
<svg
className="w-12 h-12 text-gray-900 ml-1"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,201 @@
/**
* Click logging service for admin frontend.
* Sends click events to backend API for logging to file.
*/
import { apiClient } from '../api/client'
interface ClickLogData {
page: string
element_type: string
element_id?: string
element_text?: string
context?: Record<string, unknown>
}
// Batch clicks to avoid excessive API calls
const CLICK_BATCH_SIZE = 10
const CLICK_BATCH_DELAY = 1000 // 1 second
let clickQueue: ClickLogData[] = []
let batchTimeout: number | null = null
/**
* Get the current page path.
*/
function getCurrentPage(): string {
return window.location.pathname
}
/**
* Get element type from HTML element.
*/
function getElementType(element: HTMLElement): string {
const tagName = element.tagName.toLowerCase()
// Map common elements
if (tagName === 'button' || element.getAttribute('role') === 'button') {
return 'button'
}
if (tagName === 'a') {
return 'link'
}
if (tagName === 'input') {
return 'input'
}
if (tagName === 'select') {
return 'select'
}
if (tagName === 'textarea') {
return 'textarea'
}
// Check for clickable elements
if (element.onclick || element.getAttribute('onclick')) {
return 'clickable'
}
// Default to tag name
return tagName
}
/**
* Get element text content (truncated to 100 chars).
*/
function getElementText(element: HTMLElement): string {
const text = element.textContent?.trim() || element.getAttribute('aria-label') || ''
return text.substring(0, 100)
}
/**
* Extract context from element (data attributes, etc.).
*/
function extractContext(element: HTMLElement): Record<string, unknown> {
const context: Record<string, unknown> = {}
// Extract data-* attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-')) {
const key = attr.name.replace('data-', '').replace(/-/g, '_')
context[key] = attr.value
}
})
// Extract common IDs that might be useful
const id = element.id
if (id) {
context.element_id = id
}
const className = element.className
if (className && typeof className === 'string') {
context.class_name = className.split(' ').slice(0, 3).join(' ') // First 3 classes
}
return context
}
/**
* Flush queued clicks to backend.
*/
async function flushClickQueue(): Promise<void> {
if (clickQueue.length === 0) {
return
}
const clicksToSend = [...clickQueue]
clickQueue = []
// Send clicks in parallel (but don't wait for all to complete)
clicksToSend.forEach(clickData => {
apiClient.post('/api/v1/log/click', clickData).catch(error => {
// Silently fail - don't interrupt user experience
console.debug('Click logging failed:', error)
})
})
}
/**
* Queue a click for logging.
*/
function queueClick(clickData: ClickLogData): void {
clickQueue.push(clickData)
// Flush if batch size reached
if (clickQueue.length >= CLICK_BATCH_SIZE) {
if (batchTimeout !== null) {
window.clearTimeout(batchTimeout)
batchTimeout = null
}
flushClickQueue()
} else {
// Set timeout to flush after delay
if (batchTimeout === null) {
batchTimeout = window.setTimeout(() => {
batchTimeout = null
flushClickQueue()
}, CLICK_BATCH_DELAY)
}
}
}
/**
* Log a click event.
*/
export function logClick(
element: HTMLElement,
additionalContext?: Record<string, unknown>
): void {
try {
const elementType = getElementType(element)
const elementId = element.id || undefined
const elementText = getElementText(element)
const page = getCurrentPage()
const context = {
...extractContext(element),
...additionalContext,
}
// Skip logging for certain elements (to reduce noise)
const skipSelectors = [
'input[type="password"]',
'input[type="hidden"]',
'[data-no-log]', // Allow opt-out via data attribute
]
const shouldSkip = skipSelectors.some(selector => {
try {
return element.matches(selector)
} catch {
return false
}
})
if (shouldSkip) {
return
}
queueClick({
page,
element_type: elementType,
element_id: elementId,
element_text: elementText || undefined,
context: Object.keys(context).length > 0 ? context : undefined,
})
} catch (error) {
// Silently fail - don't interrupt user experience
console.debug('Click logging error:', error)
}
}
/**
* Flush any pending clicks (useful on page unload).
*/
export function flushPendingClicks(): void {
if (batchTimeout !== null) {
window.clearTimeout(batchTimeout)
batchTimeout = null
}
flushClickQueue()
}

View File

@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_DEVELOPER_MODE?: string
}
interface ImportMeta {

View File

@ -1,17 +1,29 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const raw = (env.VITE_BASE_PATH || '').trim()
const base =
raw === ''
? '/'
: raw.endsWith('/')
? raw
: `${raw}/`
return {
base,
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
},
}
})

View File

@ -3,11 +3,12 @@
from __future__ import annotations
import os
import uuid
from datetime import datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials
from jose import JWTError, jwt
from sqlalchemy.orm import Session
@ -30,10 +31,50 @@ from backend.schemas.auth import (
from backend.services.role_permissions import fetch_role_permissions_map
router = APIRouter(prefix="/auth", tags=["auth"])
security = HTTPBearer()
# Placeholder secrets - replace with env vars in production
SECRET_KEY = "dev-secret-key-change-in-production"
def get_bearer_token(request: Request) -> HTTPAuthorizationCredentials:
"""Custom security dependency that returns 401 for missing tokens (not 403).
This replaces HTTPBearer() to follow HTTP standards where missing authentication
should return 401 Unauthorized, not 403 Forbidden.
"""
authorization = request.headers.get("Authorization")
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# Parse Authorization header: "Bearer <token>"
parts = authorization.split(" ", 1)
if len(parts) != 2:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
headers={"WWW-Authenticate": "Bearer"},
)
scheme, credentials = parts
if scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
headers={"WWW-Authenticate": "Bearer"},
)
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
# Read secrets from environment variables
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 360
REFRESH_TOKEN_EXPIRE_DAYS = 7
@ -47,7 +88,7 @@ def create_access_token(data: dict, expires_delta: timedelta) -> str:
"""Create JWT access token."""
to_encode = data.copy()
expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire})
to_encode.update({"exp": expire, "jti": str(uuid.uuid4())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@ -55,12 +96,34 @@ def create_refresh_token(data: dict) -> str:
"""Create JWT refresh token."""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user_from_token(token: str) -> dict:
"""Get current user from JWT token string (for query parameter auth).
Used for endpoints that need authentication but can't use headers
(e.g., EventSource/SSE endpoints).
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
return {"username": username}
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]
credentials: Annotated[HTTPAuthorizationCredentials, Depends(get_bearer_token)]
) -> dict:
"""Get current user from JWT token."""
try:
@ -303,9 +366,18 @@ def get_current_user_info(
is_admin = user.is_admin if user else False
role_value = _resolve_user_role(user, is_admin)
permissions_map = fetch_role_permissions_map(db)
permissions = permissions_map.get(role_value, {})
# Fetch permissions - if it fails, return empty permissions to avoid blocking login
try:
permissions_map = fetch_role_permissions_map(db)
permissions = permissions_map.get(role_value, {})
except Exception as e:
# If permissions fetch fails, return empty permissions to avoid blocking login
# Log the error but don't fail the request
import traceback
print(f"⚠️ Failed to fetch permissions for /me endpoint: {e}")
print(f" Traceback: {traceback.format_exc()}")
permissions = {}
return UserResponse(
username=username,
is_admin=is_admin,

View File

@ -69,6 +69,8 @@ def list_auth_users(
select_fields += ", role"
select_fields += ", created_at, updated_at"
# nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
# Safe: select_fields is controlled (column names only, not user input)
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@ -83,6 +85,8 @@ def list_auth_users(
if has_is_active_column:
select_fields += ", is_active"
select_fields += ", created_at, updated_at"
# nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
# Safe: select_fields is controlled (column names only, not user input)
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@ -291,6 +295,8 @@ def get_auth_user(
select_fields += ", role"
select_fields += ", created_at, updated_at"
# nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
# Safe: select_fields is controlled (column names only, not user input), user_id is parameterized
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@ -305,6 +311,8 @@ def get_auth_user(
if has_is_active_column:
select_fields += ", is_active"
select_fields += ", created_at, updated_at"
# nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
# Safe: select_fields is controlled (column names only, not user input), user_id is parameterized
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@ -450,6 +458,8 @@ def update_auth_user(
if has_role_column:
select_fields += ", role"
select_fields += ", created_at, updated_at"
# nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
# Safe: update_sql and select_fields are controlled (column names only, not user input), params are parameterized
result = auth_db.execute(text(f"""
{update_sql}
RETURNING {select_fields}

56
backend/api/click_log.py Normal file
View File

@ -0,0 +1,56 @@
"""Click logging API endpoint."""
from __future__ import annotations
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from backend.api.auth import get_current_user
from backend.utils.click_logger import log_click
router = APIRouter(prefix="/log", tags=["logging"])
class ClickLogRequest(BaseModel):
"""Request model for click logging."""
page: str
element_type: str
element_id: Optional[str] = None
element_text: Optional[str] = None
context: Optional[dict] = None
@router.post("/click")
def log_click_event(
request: ClickLogRequest,
current_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""Log a click event from the admin frontend.
Args:
request: Click event data
current_user: Authenticated user (from JWT token)
Returns:
Success confirmation
"""
username = current_user.get("username", "unknown")
try:
log_click(
username=username,
page=request.page,
element_type=request.element_type,
element_id=request.element_id,
element_text=request.element_text,
context=request.context,
)
return {"status": "ok", "message": "Click logged"}
except Exception as e:
# Don't fail the request if logging fails
# Just return success but log the error
import logging
logging.error(f"Failed to log click: {e}")
return {"status": "ok", "message": "Click logged (with errors)"}

View File

@ -90,9 +90,9 @@ def process_faces(request: ProcessFacesRequest) -> ProcessFacesResponse:
job_timeout="1h", # Long timeout for face processing
)
print(f"[Faces API] Enqueued face processing job: {job.id}")
print(f"[Faces API] Job status: {job.get_status()}")
print(f"[Faces API] Queue length: {len(queue)}")
import logging
logger = logging.getLogger(__name__)
logger.info(f"Enqueued face processing job: {job.id}, status: {job.get_status()}, queue length: {len(queue)}")
return ProcessFacesResponse(
job_id=job.id,
@ -197,12 +197,14 @@ def get_unidentified_faces(
def get_similar_faces(
face_id: int,
include_excluded: bool = Query(False, description="Include excluded faces in results"),
debug: bool = Query(False, description="Include debug information (encoding stats) in response"),
db: Session = Depends(get_db)
) -> SimilarFacesResponse:
"""Return similar unidentified faces for a given face."""
import logging
import numpy as np
logger = logging.getLogger(__name__)
logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}")
logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}, debug={debug}")
# Validate face exists
base = db.query(Face).filter(Face.id == face_id).first()
@ -210,8 +212,23 @@ def get_similar_faces(
logger.warning(f"API: Face {face_id} not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Face {face_id} not found")
# Load base encoding for debug info if needed
base_debug_info = None
if debug:
from backend.services.face_service import load_face_encoding
base_enc = load_face_encoding(base.encoding)
base_debug_info = {
"encoding_length": len(base_enc),
"encoding_min": float(np.min(base_enc)),
"encoding_max": float(np.max(base_enc)),
"encoding_mean": float(np.mean(base_enc)),
"encoding_std": float(np.std(base_enc)),
"encoding_first_10": [float(x) for x in base_enc[:10].tolist()],
}
logger.info(f"API: Calling find_similar_faces for face_id={face_id}, include_excluded={include_excluded}")
results = find_similar_faces(db, face_id, include_excluded=include_excluded)
# Use 0.6 tolerance for Identify People (more lenient for manual review)
results = find_similar_faces(db, face_id, tolerance=0.6, include_excluded=include_excluded, debug=debug)
logger.info(f"API: find_similar_faces returned {len(results)} results")
items = [
@ -223,12 +240,13 @@ def get_similar_faces(
quality_score=float(f.quality_score),
filename=f.photo.filename if f.photo else "unknown",
pose_mode=getattr(f, "pose_mode", None) or "frontal",
debug_info=debug_info if debug else None,
)
for f, distance, confidence_pct in results
for f, distance, confidence_pct, debug_info in results
]
logger.info(f"API: Returning {len(items)} items for face_id={face_id}")
return SimilarFacesResponse(base_face_id=face_id, items=items)
return SimilarFacesResponse(base_face_id=face_id, items=items, debug_info=base_debug_info)
@router.post("/batch-similarity", response_model=BatchSimilarityResponse)
@ -246,10 +264,12 @@ def get_batch_similarities(
logger.info(f"API: batch_similarity called for {len(request.face_ids)} faces")
# Calculate similarities between all pairs
# Use 0.6 tolerance for Identify People (more lenient for manual review)
pairs = calculate_batch_similarities(
db,
request.face_ids,
min_confidence=request.min_confidence,
tolerance=0.6,
)
# Convert to response format
@ -435,7 +455,9 @@ def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response:
except HTTPException:
raise
except Exception as e:
print(f"[Faces API] get_face_crop error for face {face_id}: {e}")
import logging
logger = logging.getLogger(__name__)
logger.error(f"get_face_crop error for face {face_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract face crop: {str(e)}",
@ -607,10 +629,12 @@ def auto_match_faces(
# Find matches for all identified people
# Filter by frontal reference faces if auto_accept enabled
# Use distance-based thresholds only when auto_accept is enabled (Run auto-match button)
matches_data = find_auto_match_matches(
db,
tolerance=request.tolerance,
filter_frontal_only=request.auto_accept
filter_frontal_only=request.auto_accept,
use_distance_based_thresholds=request.use_distance_based_thresholds or request.auto_accept
)
# If auto_accept enabled, process matches automatically
@ -644,7 +668,9 @@ def auto_match_faces(
)
auto_accepted_faces += identified_count
except Exception as e:
print(f"Error auto-accepting matches for person {person_id}: {e}")
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error auto-accepting matches for person {person_id}: {e}")
if not matches_data:
return AutoMatchResponse(
@ -747,7 +773,7 @@ def auto_match_faces(
@router.get("/auto-match/people", response_model=AutoMatchPeopleResponse)
def get_auto_match_people(
filter_frontal_only: bool = Query(False, description="Only include frontal/tilted reference faces"),
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"),
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"),
db: Session = Depends(get_db),
) -> AutoMatchPeopleResponse:
"""Get list of people for auto-match (without matches) - fast initial load.
@ -810,7 +836,7 @@ def get_auto_match_people(
@router.get("/auto-match/people/{person_id}/matches", response_model=AutoMatchPersonMatchesResponse)
def get_auto_match_person_matches(
person_id: int,
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"),
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"),
filter_frontal_only: bool = Query(False, description="Only return frontal/tilted faces"),
db: Session = Depends(get_db),
) -> AutoMatchPersonMatchesResponse:

View File

@ -4,15 +4,17 @@ from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from rq import Queue
from rq.job import Job
from redis import Redis
import json
import time
from typing import Optional
from backend.schemas.jobs import JobResponse, JobStatus
from backend.api.auth import get_current_user_from_token
router = APIRouter(prefix="/jobs", tags=["jobs"])
@ -89,8 +91,26 @@ def get_job(job_id: str) -> JobResponse:
@router.get("/stream/{job_id}")
def stream_job_progress(job_id: str):
"""Stream job progress via Server-Sent Events (SSE)."""
def stream_job_progress(
job_id: str,
token: Optional[str] = Query(None, description="JWT token for authentication"),
):
"""Stream job progress via Server-Sent Events (SSE).
Note: EventSource cannot send custom headers, so authentication
is done via query parameter 'token'.
"""
# Authenticate user via token query parameter (required for EventSource)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required. Provide 'token' query parameter.",
)
try:
get_current_user_from_token(token)
except HTTPException as e:
raise e
def event_generator():
"""Generate SSE events for job progress."""

View File

@ -138,6 +138,8 @@ def list_pending_linkages(
status_clause = "WHERE pl.status = :status_filter"
params["status_filter"] = status_filter
# nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
# Safe: SQL uses only column names (no user input in query structure)
result = auth_db.execute(
text(
f"""

View File

@ -266,115 +266,246 @@ def review_pending_photos(
"""
import shutil
import uuid
import traceback
import logging
logger = logging.getLogger(__name__)
approved_count = 0
rejected_count = 0
duplicate_count = 0
errors = []
admin_user_id = current_user.get("user_id")
now = datetime.utcnow()
# Base directories
# Try to get upload directory from environment, fallback to hardcoded path
upload_base_dir = Path(os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "/mnt/db-server-uploads")
main_storage_dir = Path(PHOTO_STORAGE_DIR)
main_storage_dir.mkdir(parents=True, exist_ok=True)
for decision in request.decisions:
try:
admin_user_id = current_user.get("user_id")
if not admin_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User ID not found in authentication token"
)
now = datetime.utcnow()
# Base directories
# Try to get upload directory from environment, fallback to hardcoded path
upload_base_dir = Path(os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "/mnt/db-server-uploads")
# Resolve PHOTO_STORAGE_DIR relative to project root (/opt/punimtag)
# If it's already absolute, use it as-is; otherwise resolve relative to project root
photo_storage_path = PHOTO_STORAGE_DIR
if not os.path.isabs(photo_storage_path):
# Get project root (backend/api/pending_photos.py -> backend/api -> backend -> project root)
project_root = Path(__file__).resolve().parents[2]
main_storage_dir = project_root / photo_storage_path
else:
main_storage_dir = Path(photo_storage_path)
# Ensure main storage directory exists
# Try to create the directory and all parent directories
try:
# Get pending photo from auth database with file info
# Only allow processing 'pending' status photos
result = auth_db.execute(text("""
SELECT
pp.id,
pp.status,
pp.file_path,
pp.filename,
pp.original_filename
FROM pending_photos pp
WHERE pp.id = :id AND pp.status = 'pending'
"""), {"id": decision.id})
# Check if parent directory exists and is writable
parent_dir = main_storage_dir.parent
if parent_dir.exists():
if not os.access(parent_dir, os.W_OK):
error_msg = (
f"Permission denied: Cannot create directory {main_storage_dir}. "
f"Parent directory {parent_dir} exists but is not writable. "
f"Please ensure the directory is writable by the application user (appuser)."
)
logger.error(error_msg)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_msg
)
row = result.fetchone()
if not row:
errors.append(f"Pending photo {decision.id} not found or already reviewed")
continue
# Create directory and all parent directories
main_storage_dir.mkdir(parents=True, exist_ok=True)
if decision.decision == 'approve':
# Find the source file
db_file_path = row.file_path
source_path = None
# Verify we can write to it
if not os.access(main_storage_dir, os.W_OK):
error_msg = (
f"Permission denied: Directory {main_storage_dir} exists but is not writable. "
f"Please ensure the directory is writable by the application user (appuser)."
)
logger.error(error_msg)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_msg
)
# Try to find the file - handle both absolute and relative paths
if os.path.isabs(db_file_path):
# Use absolute path directly
source_path = Path(db_file_path)
else:
# Try relative to upload base directory
source_path = upload_base_dir / db_file_path
except HTTPException:
# Re-raise HTTP exceptions
raise
except PermissionError as e:
error_msg = (
f"Permission denied creating main storage directory {main_storage_dir}. "
f"Error: {str(e)}. Please ensure the directory and parent directories are writable by the application user (appuser)."
)
logger.error(error_msg)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_msg
)
except Exception as e:
error_msg = f"Failed to create main storage directory {main_storage_dir}: {str(e)}"
logger.error(error_msg)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_msg
)
if not request.decisions:
return ReviewResponse(
approved=0,
rejected=0,
errors=["No decisions provided"],
warnings=[]
)
for decision in request.decisions:
try:
# Get pending photo from auth database with file info
# Only allow processing 'pending' status photos
result = auth_db.execute(text("""
SELECT
pp.id,
pp.status,
pp.file_path,
pp.filename,
pp.original_filename
FROM pending_photos pp
WHERE pp.id = :id AND pp.status = 'pending'
"""), {"id": decision.id})
# If file doesn't exist, try alternative locations
if not source_path.exists():
# Try with just the filename in upload_base_dir
source_path = upload_base_dir / row.filename
if not source_path.exists() and row.original_filename:
# Try with original filename
source_path = upload_base_dir / row.original_filename
# If still not found, try looking in user subdirectories
if not source_path.exists() and upload_base_dir.exists():
# Check if file_path contains user ID subdirectory
# file_path format might be: {userId}/{filename} or full path
try:
for user_id_dir in upload_base_dir.iterdir():
if user_id_dir.is_dir():
potential_path = user_id_dir / row.filename
if potential_path.exists():
source_path = potential_path
break
if row.original_filename:
potential_path = user_id_dir / row.original_filename
row = result.fetchone()
if not row:
errors.append(f"Pending photo {decision.id} not found or already reviewed")
continue
if decision.decision == 'approve':
# Find the source file
db_file_path = row.file_path
source_path = None
# Try to find the file - handle both absolute and relative paths
if os.path.isabs(db_file_path):
# Use absolute path directly
source_path = Path(db_file_path)
else:
# Try relative to upload base directory
source_path = upload_base_dir / db_file_path
# If file doesn't exist, try alternative locations
if not source_path.exists():
# Try with just the filename in upload_base_dir
source_path = upload_base_dir / row.filename
if not source_path.exists() and row.original_filename:
# Try with original filename
source_path = upload_base_dir / row.original_filename
# If still not found, try looking in user subdirectories
if not source_path.exists() and upload_base_dir.exists():
# Check if file_path contains user ID subdirectory
# file_path format might be: {userId}/{filename} or full path
try:
for user_id_dir in upload_base_dir.iterdir():
if user_id_dir.is_dir():
potential_path = user_id_dir / row.filename
if potential_path.exists():
source_path = potential_path
break
except (PermissionError, OSError) as e:
# Can't read directory, skip this search
pass
if not source_path.exists():
errors.append(f"Photo file not found for pending photo {decision.id}. Tried: {db_file_path}, {upload_base_dir / row.filename}, {upload_base_dir / row.original_filename if row.original_filename else 'N/A'}")
continue
# Calculate file hash and check for duplicates BEFORE moving file
try:
file_hash = calculate_file_hash(str(source_path))
except Exception as e:
errors.append(f"Failed to calculate hash for pending photo {decision.id}: {str(e)}")
continue
# Check if photo with same hash already exists in main database
# Handle case where file_hash column might not exist or be NULL for old photos
try:
existing_photo = main_db.execute(text("""
SELECT id, path FROM photos WHERE file_hash = :file_hash AND file_hash IS NOT NULL
"""), {"file_hash": file_hash}).fetchone()
except Exception as e:
# If file_hash column doesn't exist, skip duplicate check
# This can happen if database schema is outdated
if "no such column" in str(e).lower() or "file_hash" in str(e).lower():
existing_photo = None
else:
raise
if existing_photo:
# Photo already exists - mark as duplicate and skip import
# Don't add to errors - we'll show a summary message instead
# Update status to rejected with duplicate reason
if row.original_filename:
potential_path = user_id_dir / row.original_filename
if potential_path.exists():
source_path = potential_path
break
except (PermissionError, OSError) as e:
# Can't read directory, skip this search
pass
if not source_path.exists():
errors.append(f"Photo file not found for pending photo {decision.id}. Tried: {db_file_path}, {upload_base_dir / row.filename}, {upload_base_dir / row.original_filename if row.original_filename else 'N/A'}")
continue
# Calculate file hash and check for duplicates BEFORE moving file
try:
file_hash = calculate_file_hash(str(source_path))
except Exception as e:
errors.append(f"Failed to calculate hash for pending photo {decision.id}: {str(e)}")
continue
# Check if photo with same hash already exists in main database
# Handle case where file_hash column might not exist or be NULL for old photos
try:
existing_photo = main_db.execute(text("""
SELECT id, path FROM photos WHERE file_hash = :file_hash AND file_hash IS NOT NULL
"""), {"file_hash": file_hash}).fetchone()
except Exception as e:
# If file_hash column doesn't exist, skip duplicate check
# This can happen if database schema is outdated
if "no such column" in str(e).lower() or "file_hash" in str(e).lower():
existing_photo = None
else:
raise
if existing_photo:
# Photo already exists - mark as duplicate and skip import
# Don't add to errors - we'll show a summary message instead
# Update status to rejected with duplicate reason
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'rejected',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
rejection_reason = 'Duplicate photo already exists in database'
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
})
auth_db.commit()
rejected_count += 1
duplicate_count += 1
continue
# Generate unique filename for main storage to avoid conflicts
file_ext = source_path.suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
dest_path = main_storage_dir / unique_filename
# Copy file to main storage (keep original in shared location)
try:
shutil.copy2(str(source_path), str(dest_path))
except Exception as e:
errors.append(f"Failed to copy photo file for {decision.id}: {str(e)}")
continue
# Import photo into main database (Scan process)
# This will also check for duplicates by hash, but we've already checked above
try:
photo, is_new = import_photo_from_path(main_db, str(dest_path))
if not is_new:
# Photo already exists (shouldn't happen due to hash check above, but handle gracefully)
if dest_path.exists():
dest_path.unlink()
errors.append(f"Photo already exists in main database: {photo.path}")
continue
except Exception as e:
# If import fails, delete the copied file (original remains in shared location)
if dest_path.exists():
try:
dest_path.unlink()
except:
pass
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
continue
# Update status to approved in auth database
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'rejected',
SET status = 'approved',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
rejection_reason = 'Duplicate photo already exists in database'
reviewed_by = :reviewed_by
WHERE id = :id
"""), {
"id": decision.id,
@ -382,99 +513,61 @@ def review_pending_photos(
"reviewed_by": admin_user_id,
})
auth_db.commit()
approved_count += 1
elif decision.decision == 'reject':
# Update status to rejected
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'rejected',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
rejection_reason = :rejection_reason
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"rejection_reason": decision.rejection_reason or None,
})
auth_db.commit()
rejected_count += 1
duplicate_count += 1
continue
# Generate unique filename for main storage to avoid conflicts
file_ext = source_path.suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
dest_path = main_storage_dir / unique_filename
# Copy file to main storage (keep original in shared location)
try:
shutil.copy2(str(source_path), str(dest_path))
except Exception as e:
errors.append(f"Failed to copy photo file for {decision.id}: {str(e)}")
continue
# Import photo into main database (Scan process)
# This will also check for duplicates by hash, but we've already checked above
try:
photo, is_new = import_photo_from_path(main_db, str(dest_path))
if not is_new:
# Photo already exists (shouldn't happen due to hash check above, but handle gracefully)
if dest_path.exists():
dest_path.unlink()
errors.append(f"Photo already exists in main database: {photo.path}")
continue
except Exception as e:
# If import fails, delete the copied file (original remains in shared location)
if dest_path.exists():
try:
dest_path.unlink()
except:
pass
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
continue
# Update status to approved in auth database
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'approved',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
})
auth_db.commit()
approved_count += 1
elif decision.decision == 'reject':
# Update status to rejected
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'rejected',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
rejection_reason = :rejection_reason
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"rejection_reason": decision.rejection_reason or None,
})
auth_db.commit()
rejected_count += 1
else:
errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
except Exception as e:
errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
# Rollback any partial changes
auth_db.rollback()
main_db.rollback()
# Add friendly message about duplicates if any were found
warnings = []
if duplicate_count > 0:
if duplicate_count == 1:
warnings.append(f"{duplicate_count} photo was not added as it already exists in the database")
else:
errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
except Exception as e:
errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
# Rollback any partial changes
auth_db.rollback()
main_db.rollback()
# Add friendly message about duplicates if any were found
warnings = []
if duplicate_count > 0:
if duplicate_count == 1:
warnings.append(f"{duplicate_count} photo was not added as it already exists in the database")
else:
warnings.append(f"{duplicate_count} photos were not added as they already exist in the database")
return ReviewResponse(
approved=approved_count,
rejected=rejected_count,
errors=errors,
warnings=warnings
)
warnings.append(f"{duplicate_count} photos were not added as they already exist in the database")
return ReviewResponse(
approved=approved_count,
rejected=rejected_count,
errors=errors,
warnings=warnings
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
# Catch any unexpected errors and log them
error_traceback = traceback.format_exc()
logger.error(f"Unexpected error in review_pending_photos: {str(e)}\n{error_traceback}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal server error while processing photo review: {str(e)}"
)
class CleanupResponse(BaseModel):

View File

@ -6,7 +6,7 @@ from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.db.session import get_db
@ -48,12 +48,12 @@ def list_people(
@router.get("/with-faces", response_model=PeopleWithFacesListResponse)
def list_people_with_faces(
last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"),
last_name: str | None = Query(None, description="Filter by first, middle, last, or maiden name (case-insensitive)"),
db: Session = Depends(get_db),
) -> PeopleWithFacesListResponse:
"""List all people with face counts and video counts, sorted by last_name, first_name.
Optionally filter by last_name or maiden_name if provided (case-insensitive search).
Optionally filter by first_name, middle_name, last_name, or maiden_name if provided (case-insensitive search).
Returns all people, including those with zero faces or videos.
"""
# Query people with face counts using LEFT OUTER JOIN to include people with no faces
@ -67,11 +67,15 @@ def list_people_with_faces(
)
if last_name:
# Case-insensitive search on both last_name and maiden_name
# Case-insensitive search on first_name, middle_name, last_name, and maiden_name
search_term = last_name.lower()
query = query.filter(
(func.lower(Person.last_name).contains(search_term)) |
((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
or_(
func.lower(Person.first_name).contains(search_term),
func.lower(Person.middle_name).contains(search_term),
func.lower(Person.last_name).contains(search_term),
((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
)
)
results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
@ -266,9 +270,17 @@ def accept_matches(
from backend.api.auth import get_current_user_with_id
user_id = current_user["user_id"]
identified_count, updated_count = accept_auto_match_matches(
db, person_id, request.face_ids, user_id=user_id
)
try:
identified_count, updated_count = accept_auto_match_matches(
db, person_id, request.face_ids, user_id=user_id
)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
raise
return IdentifyFaceResponse(
identified_face_ids=request.face_ids,

View File

@ -5,8 +5,8 @@ from __future__ import annotations
from datetime import date, datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import JSONResponse, FileResponse
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status, Request
from fastapi.responses import JSONResponse, FileResponse, Response
from typing import Annotated
from rq import Queue
from redis import Redis
@ -29,6 +29,8 @@ from backend.schemas.photos import (
BulkDeletePhotosResponse,
BulkRemoveFavoritesRequest,
BulkRemoveFavoritesResponse,
BrowseDirectoryResponse,
DirectoryItem,
)
from backend.schemas.search import (
PhotoSearchResult,
@ -130,6 +132,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=full_name,
tags=tags,
has_faces=face_count > 0,
@ -158,6 +161,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@ -193,6 +197,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@ -214,6 +219,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=None,
tags=tags,
has_faces=False,
@ -236,6 +242,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=person_name_val,
tags=[],
has_faces=face_count > 0,
@ -259,6 +266,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@ -282,6 +290,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@ -310,6 +319,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@ -329,6 +339,7 @@ def search_photos(
@router.post("/import", response_model=PhotoImportResponse)
def import_photos(
request: PhotoImportRequest,
current_user: Annotated[dict, Depends(get_current_user)],
) -> PhotoImportResponse:
"""Import photos from a folder path.
@ -371,7 +382,7 @@ def import_photos(
@router.post("/import/upload")
async def upload_photos(
files: list[UploadFile] = File(...),
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Upload photo files directly.
@ -383,6 +394,7 @@ async def upload_photos(
import os
import shutil
from pathlib import Path
from datetime import datetime, date
from backend.settings import PHOTO_STORAGE_DIR
@ -394,6 +406,49 @@ async def upload_photos(
existing_count = 0
errors = []
# Read form data first to get both files and metadata
form_data = await request.form()
import logging
logger = logging.getLogger(__name__)
# Extract file metadata (EXIF dates and original modification timestamps) from form data
# These are captured from the ORIGINAL file BEFORE upload, so they preserve the real dates
file_original_mtime = {}
file_exif_dates = {}
files = []
# Extract files first using getlist (handles multiple files with same key)
files = form_data.getlist('files')
# Extract metadata from form data
for key, value in form_data.items():
if key.startswith('file_exif_date_'):
# Extract EXIF date from browser (format: file_exif_date_<filename>)
filename = key.replace('file_exif_date_', '')
file_exif_dates[filename] = str(value)
elif key.startswith('file_original_mtime_'):
# Extract original file modification time from browser (format: file_original_mtime_<filename>)
# This is the modification date from the ORIGINAL file before upload
filename = key.replace('file_original_mtime_', '')
try:
file_original_mtime[filename] = int(value)
except (ValueError, TypeError) as e:
logger.debug(f"Could not parse original mtime for {filename}: {e}")
# If no files found in form_data, try to get them from request directly
if not files:
# Fallback: try to get files from request.files() if available
try:
if hasattr(request, '_form'):
form = await request.form()
files = form.getlist('files')
except:
pass
if not files:
raise HTTPException(status_code=400, detail="No files provided")
for file in files:
try:
# Generate unique filename to avoid conflicts
@ -408,8 +463,63 @@ async def upload_photos(
with open(stored_path, "wb") as f:
f.write(content)
# Extract date metadata from browser BEFORE upload
# Priority: 1) Browser EXIF date, 2) Original file modification date (from before upload)
# This ensures we use the ORIGINAL file's metadata, not the server's copy
browser_exif_date = None
file_last_modified = None
# First try: Use EXIF date extracted in browser (from original file)
if file.filename in file_exif_dates:
exif_date_str = file_exif_dates[file.filename]
logger.info(f"[UPLOAD] Found browser EXIF date for {file.filename}: {exif_date_str}")
try:
# Parse EXIF date string (format: "YYYY:MM:DD HH:MM:SS" or ISO format)
from dateutil import parser
exif_datetime = parser.parse(exif_date_str)
browser_exif_date = exif_datetime.date()
# Validate the date
if browser_exif_date > date.today() or browser_exif_date < date(1900, 1, 1):
logger.warning(f"[UPLOAD] Browser EXIF date {browser_exif_date} is invalid for {file.filename}, trying original mtime")
browser_exif_date = None
else:
logger.info(f"[UPLOAD] Parsed browser EXIF date: {browser_exif_date} for {file.filename}")
except Exception as e:
logger.warning(f"[UPLOAD] Could not parse browser EXIF date '{exif_date_str}' for {file.filename}: {e}, trying original mtime")
browser_exif_date = None
else:
logger.debug(f"[UPLOAD] No browser EXIF date found for {file.filename}")
# Second try: Use original file modification time (captured BEFORE upload)
if file.filename in file_original_mtime:
timestamp_ms = file_original_mtime[file.filename]
logger.info(f"[UPLOAD] Found original mtime for {file.filename}: {timestamp_ms}")
try:
file_last_modified = datetime.fromtimestamp(timestamp_ms / 1000.0).date()
# Validate the date
if file_last_modified > date.today() or file_last_modified < date(1900, 1, 1):
logger.warning(f"[UPLOAD] Original file mtime {file_last_modified} is invalid for {file.filename}")
file_last_modified = None
else:
logger.info(f"[UPLOAD] Parsed original mtime: {file_last_modified} for {file.filename}")
except (ValueError, OSError) as e:
logger.warning(f"[UPLOAD] Could not parse original mtime timestamp {timestamp_ms} for {file.filename}: {e}")
file_last_modified = None
else:
logger.debug(f"[UPLOAD] No original mtime found for {file.filename}")
logger.info(f"[UPLOAD] Calling import_photo_from_path for {file.filename} with browser_exif_date={browser_exif_date}, file_last_modified={file_last_modified}")
# Import photo from stored location
photo, is_new = import_photo_from_path(db, str(stored_path))
# Pass browser-extracted EXIF date and file modification time separately
# Priority: browser_exif_date > server EXIF extraction > file_last_modified
photo, is_new = import_photo_from_path(
db,
str(stored_path),
is_uploaded_file=True,
file_last_modified=file_last_modified,
browser_exif_date=browser_exif_date
)
if is_new:
added_count += 1
else:
@ -428,6 +538,112 @@ async def upload_photos(
}
@router.get("/browse-directory", response_model=BrowseDirectoryResponse)
def browse_directory(
current_user: Annotated[dict, Depends(get_current_user)],
path: str = Query("/", description="Directory path to list"),
) -> BrowseDirectoryResponse:
"""List directories and files in a given path.
No GUI required - uses os.listdir() to read filesystem.
Returns JSON with directory structure for web-based folder browser.
Args:
path: Directory path to list (can be relative or absolute)
Returns:
BrowseDirectoryResponse with current path, parent path, and items list
Raises:
HTTPException: If path doesn't exist, is not a directory, or access is denied
"""
import os
from pathlib import Path
try:
# Convert to absolute path
abs_path = os.path.abspath(path)
# Normalize path separators
abs_path = os.path.normpath(abs_path)
# Security: Optional - restrict to certain base paths
# For now, allow any path (server admin should configure file permissions)
# You can uncomment and customize this for production:
# allowed_bases = ["/home", "/mnt", "/opt/punimtag", "/media"]
# if not any(abs_path.startswith(base) for base in allowed_bases):
# raise HTTPException(
# status_code=status.HTTP_403_FORBIDDEN,
# detail=f"Path not allowed: {abs_path}"
# )
if not os.path.exists(abs_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Path does not exist: {abs_path}",
)
if not os.path.isdir(abs_path):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Path is not a directory: {abs_path}",
)
# Read directory contents
items = []
try:
for item in os.listdir(abs_path):
item_path = os.path.join(abs_path, item)
full_path = os.path.abspath(item_path)
# Skip if we can't access it (permission denied)
try:
is_dir = os.path.isdir(full_path)
is_file = os.path.isfile(full_path)
except (OSError, PermissionError):
# Skip items we can't access
continue
items.append(
DirectoryItem(
name=item,
path=full_path,
is_directory=is_dir,
is_file=is_file,
)
)
except PermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied reading directory: {abs_path}",
)
# Sort: directories first, then files, both alphabetically
items.sort(key=lambda x: (not x.is_directory, x.name.lower()))
# Get parent path (None if at root)
parent_path = None
if abs_path != "/" and abs_path != os.path.dirname(abs_path):
parent_path = os.path.dirname(abs_path)
# Normalize parent path
parent_path = os.path.normpath(parent_path)
return BrowseDirectoryResponse(
current_path=abs_path,
parent_path=parent_path,
items=items,
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading directory: {str(e)}",
)
@router.post("/browse-folder")
def browse_folder() -> dict:
"""Open native folder picker dialog and return selected folder path.
@ -556,11 +772,16 @@ def get_photo(photo_id: int, db: Session = Depends(get_db)) -> PhotoResponse:
@router.get("/{photo_id}/image")
def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileResponse:
"""Serve photo image file for display (not download)."""
def get_photo_image(
photo_id: int,
request: Request,
db: Session = Depends(get_db)
):
"""Serve photo image or video file for display (not download)."""
import os
import mimetypes
from backend.db.models import Photo
from starlette.responses import FileResponse
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
@ -575,7 +796,81 @@ def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileRespons
detail=f"Photo file not found: {photo.path}",
)
# Determine media type from file extension
# If it's a video, handle range requests for video streaming
if photo.media_type == "video":
media_type, _ = mimetypes.guess_type(photo.path)
if not media_type or not media_type.startswith('video/'):
media_type = "video/mp4"
file_size = os.path.getsize(photo.path)
# Get range header - Starlette uses lowercase
range_header = request.headers.get("range")
# Debug: log what we're getting (remove after debugging)
if photo_id == 737: # Only for this specific video
import json
debug_info = {
"range_header": range_header,
"all_headers": dict(request.headers),
"header_keys": list(request.headers.keys())
}
print(f"DEBUG photo 737: {json.dumps(debug_info, indent=2)}")
if range_header:
try:
# Parse range header: "bytes=start-end" or "bytes=start-" or "bytes=-suffix"
range_match = range_header.replace("bytes=", "").split("-")
start_str = range_match[0].strip()
end_str = range_match[1].strip() if len(range_match) > 1 and range_match[1] else ""
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
# Validate range
if start < 0:
start = 0
if end >= file_size:
end = file_size - 1
if start > end:
return Response(
status_code=416,
headers={"Content-Range": f"bytes */{file_size}"}
)
# Read the requested chunk
chunk_size = end - start + 1
with open(photo.path, "rb") as f:
f.seek(start)
chunk = f.read(chunk_size)
return Response(
content=chunk,
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Type": media_type,
"Content-Disposition": "inline",
"Cache-Control": "public, max-age=3600",
},
media_type=media_type,
)
except (ValueError, IndexError) as e:
# If range parsing fails, fall through to serve full file
pass
# No range request or parsing failed - serve full file with range support headers
response = FileResponse(
photo.path,
media_type=media_type,
)
response.headers["Content-Disposition"] = "inline"
response.headers["Accept-Ranges"] = "bytes"
response.headers["Cache-Control"] = "public, max-age=3600"
return response
# Determine media type from file extension for images
media_type, _ = mimetypes.guess_type(photo.path)
if not media_type or not media_type.startswith('image/'):
media_type = "image/jpeg"
@ -787,8 +1082,18 @@ def bulk_delete_photos(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
db: Session = Depends(get_db),
) -> BulkDeletePhotosResponse:
"""Delete multiple photos and all related data (faces, encodings, tags, favorites)."""
"""Delete multiple photos and all related data (faces, encodings, tags, favorites).
If a photo's file is in the uploads folder, it will also be deleted from the filesystem
to prevent duplicate uploads.
"""
import os
import logging
from pathlib import Path
from backend.db.models import Photo, PhotoTagLinkage
from backend.settings import PHOTO_STORAGE_DIR
logger = logging.getLogger(__name__)
photo_ids = list(dict.fromkeys(request.photo_ids))
if not photo_ids:
@ -797,13 +1102,36 @@ def bulk_delete_photos(
detail="photo_ids list cannot be empty",
)
# Get the uploads folder path for comparison
uploads_dir = Path(PHOTO_STORAGE_DIR).resolve()
try:
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
found_ids = {photo.id for photo in photos}
missing_ids = sorted(set(photo_ids) - found_ids)
deleted_count = 0
files_deleted_count = 0
for photo in photos:
# Only delete file from filesystem if it's directly in the uploads folder
# Do NOT delete files from other folders (main photo storage, etc.)
photo_path = Path(photo.path).resolve()
# Strict check: only delete if parent directory is exactly the uploads folder
if photo_path.parent == uploads_dir:
try:
if photo_path.exists():
os.remove(photo_path)
files_deleted_count += 1
logger.warning(f"DELETED file from uploads folder: {photo_path} (Photo ID: {photo.id})")
else:
logger.warning(f"Photo file not found (already deleted?): {photo_path} (Photo ID: {photo.id})")
except OSError as e:
logger.error(f"Failed to delete file {photo_path} (Photo ID: {photo.id}): {e}")
# Continue with database deletion even if file deletion fails
else:
# File is not in uploads folder - do not delete from filesystem
logger.info(f"Photo {photo.id} is not in uploads folder (path: {photo_path.parent}, uploads: {uploads_dir}), skipping file deletion")
# Remove tag linkages explicitly (in addition to cascade) to keep counts accurate
db.query(PhotoTagLinkage).filter(
PhotoTagLinkage.photo_id == photo.id
@ -824,6 +1152,8 @@ def bulk_delete_photos(
admin_username = current_admin.get("username", "unknown")
message_parts = [f"Deleted {deleted_count} photo(s)"]
if files_deleted_count > 0:
message_parts.append(f"{files_deleted_count} file(s) removed from uploads folder")
if missing_ids:
message_parts.append(f"{len(missing_ids)} photo(s) not found")
message_parts.append(f"Request by admin: {admin_username}")

View File

@ -183,6 +183,7 @@ def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTa
unidentified_face_count=p['unidentified_face_count'],
tags=p['tags'],
people_names=p.get('people_names', ''),
media_type=p.get('media_type') or 'image',
)
for p in photos_data
]

View File

@ -5,8 +5,10 @@ from __future__ import annotations
from datetime import date
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from redis import Redis
from rq import Queue
from sqlalchemy.orm import Session
from backend.db.session import get_db
@ -21,6 +23,8 @@ from backend.schemas.videos import (
IdentifyVideoRequest,
IdentifyVideoResponse,
RemoveVideoPersonResponse,
WebPlaybackPrepareResponse,
WebPlaybackStatusResponse,
)
from backend.services.video_service import (
list_videos_for_identification,
@ -30,9 +34,19 @@ from backend.services.video_service import (
get_video_people_count,
)
from backend.services.thumbnail_service import get_video_thumbnail_path
from backend.services.web_video_service import (
expire_web_playable_if_stale,
get_web_playback_status_dict,
prepare_web_playback,
resolve_valid_playable_path,
stream_web_playable_file,
)
router = APIRouter(prefix="/videos", tags=["videos"])
_redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
_video_web_queue = Queue(connection=_redis_conn)
@router.get("", response_model=ListVideosResponse)
def list_videos(
@ -296,11 +310,13 @@ def get_video_thumbnail(
@router.get("/{video_id}/video")
def get_video_file(
video_id: int,
request: Request,
db: Session = Depends(get_db),
) -> FileResponse:
"""Serve video file for playback."""
):
"""Serve video file for playback with range request support."""
import os
import mimetypes
from starlette.responses import FileResponse
# Verify video exists
video = db.query(Photo).filter(
@ -325,7 +341,73 @@ def get_video_file(
if not media_type or not media_type.startswith('video/'):
media_type = "video/mp4"
# Use FileResponse with range request support for video streaming
file_size = os.path.getsize(video.path)
range_header = request.headers.get("range")
if not range_header and hasattr(request, "scope"):
scope_headers = request.scope.get("headers", [])
for header_name, header_value in scope_headers:
if header_name.lower() == b"range":
range_header = (
header_value.decode()
if isinstance(header_value, bytes)
else header_value
)
break
if range_header:
try:
# Parse range header: "bytes=start-end"
range_match = range_header.replace("bytes=", "").split("-")
start_str = range_match[0].strip()
end_str = range_match[1].strip() if len(range_match) > 1 and range_match[1] else ""
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
# Validate range
if start < 0:
start = 0
if end >= file_size:
end = file_size - 1
if start > end:
return Response(
status_code=416,
headers={"Content-Range": f"bytes */{file_size}"}
)
# Read the requested chunk
chunk_size = end - start + 1
def generate_chunk():
with open(video.path, "rb") as f:
f.seek(start)
remaining = chunk_size
while remaining > 0:
chunk = f.read(min(8192, remaining))
if not chunk:
break
yield chunk
remaining -= len(chunk)
from fastapi.responses import StreamingResponse
return StreamingResponse(
generate_chunk(),
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Type": media_type,
"Content-Disposition": "inline",
"Cache-Control": "public, max-age=3600",
},
media_type=media_type,
)
except (ValueError, IndexError):
# If range parsing fails, fall through to serve full file
pass
# No range request or parsing failed - serve full file with range support headers
response = FileResponse(
video.path,
media_type=media_type,
@ -336,6 +418,83 @@ def get_video_file(
return response
@router.post(
"/{video_id}/web-playback/prepare",
response_model=WebPlaybackPrepareResponse,
)
def prepare_video_web_playback(
video_id: int,
db: Session = Depends(get_db),
) -> WebPlaybackPrepareResponse:
"""Queue browser-safe transcoding (deduped per video). Requires Redis + RQ worker."""
try:
_video_web_queue.connection.ping()
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Redis unavailable: {exc}",
) from exc
data = prepare_web_playback(video_id, db, _video_web_queue)
if data.get("status") == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=data.get("message", "Not found"),
)
db.commit()
return WebPlaybackPrepareResponse(
status=data["status"],
message=data.get("message", ""),
)
@router.get(
"/{video_id}/web-playback/status",
response_model=WebPlaybackStatusResponse,
)
def get_video_web_playback_status(
video_id: int,
db: Session = Depends(get_db),
) -> WebPlaybackStatusResponse:
"""Poll transcoding readiness for web playback."""
data = get_web_playback_status_dict(video_id, db)
if data["status"] == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Video not found",
)
return WebPlaybackStatusResponse(
status=data["status"],
error=data.get("error"),
)
@router.get("/{video_id}/web-playback/stream")
def stream_video_web_playback(
video_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""Stream browser-safe MP4 (after prepare + ready). Supports Range requests."""
video = (
db.query(Photo)
.filter(Photo.id == video_id, Photo.media_type == "video")
.first()
)
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video {video_id} not found",
)
expire_web_playable_if_stale(video)
db.commit()
db.refresh(video)
playable = resolve_valid_playable_path(video)
if not playable:
raise HTTPException(
status_code=getattr(status, "HTTP_425_TOO_EARLY", 425),
detail="Playback not ready. Call POST .../web-playback/prepare and poll status.",
)
return stream_web_playable_file(playable, request)

View File

@ -26,6 +26,7 @@ from backend.api.users import router as users_router
from backend.api.auth_users import router as auth_users_router
from backend.api.role_permissions import router as role_permissions_router
from backend.api.videos import router as videos_router
from backend.api.click_log import router as click_log_router
from backend.api.version import router as version_router
from backend.settings import APP_TITLE, APP_VERSION
from backend.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES
@ -56,9 +57,18 @@ def start_worker() -> None:
project_root = Path(__file__).parent.parent
# Use explicit Python path to avoid Cursor interception
# Check if sys.executable is Cursor, if so use /usr/bin/python3
# Prefer virtual environment Python if available, otherwise use system Python
python_executable = sys.executable
if "cursor" in python_executable.lower() or not python_executable.startswith("/usr"):
# If running in Cursor or not in venv, try to find venv Python
if "cursor" in python_executable.lower():
# Try to use venv Python from project root
venv_python = project_root / "venv" / "bin" / "python3"
if venv_python.exists():
python_executable = str(venv_python)
else:
python_executable = "/usr/bin/python3"
# Ensure we're using a valid Python executable
if not Path(python_executable).exists():
python_executable = "/usr/bin/python3"
# Ensure PYTHONPATH is set correctly and pass DATABASE_URL_AUTH explicitly
@ -634,7 +644,13 @@ async def lifespan(app: FastAPI):
# This must happen BEFORE we try to use the engine
ensure_postgresql_database(database_url)
# Note: Auth database is managed by the frontend, not created here
# Ensure auth database exists if configured
try:
auth_db_url = get_auth_database_url()
ensure_postgresql_database(auth_db_url)
except ValueError:
# DATABASE_URL_AUTH not set - that's okay
pass
# Only create tables if they don't already exist (safety check)
inspector = inspect(engine)
@ -672,8 +688,15 @@ async def lifespan(app: FastAPI):
try:
ensure_auth_user_is_active_column()
# Import and call worker's setup function to create all auth tables
from backend.worker import setup_auth_database_tables
setup_auth_database_tables()
# Note: This import may fail if dotenv is not installed in API environment
# (worker.py imports dotenv at top level, but API doesn't need it)
try:
from backend.worker import setup_auth_database_tables
setup_auth_database_tables()
except ImportError as import_err:
# dotenv not available in API environment - that's okay, worker will handle setup
print(f" Could not import worker setup function: {import_err}")
print(" Worker process will handle auth database setup")
except Exception as auth_exc:
# Auth database might not exist yet - that's okay, frontend will handle it
print(f" Auth database not available: {auth_exc}")
@ -696,9 +719,13 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
# CORS configuration - use environment variable for production
# Default to wildcard for development, restrict in production via CORS_ORIGINS env var
cors_origins = os.getenv("CORS_ORIGINS", "*").split(",") if os.getenv("CORS_ORIGINS") else ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -721,6 +748,7 @@ def create_app() -> FastAPI:
app.include_router(users_router, prefix="/api/v1")
app.include_router(auth_users_router, prefix="/api/v1")
app.include_router(role_permissions_router, prefix="/api/v1")
app.include_router(click_log_router, prefix="/api/v1")
return app

View File

@ -22,8 +22,13 @@ MIN_FACE_SIZE = 40
MAX_FACE_SIZE = 1500
# Matching tolerance and calibration options
DEFAULT_FACE_TOLERANCE = 0.6
DEFAULT_FACE_TOLERANCE = 0.5 # Lowered from 0.6 for stricter matching
USE_CALIBRATED_CONFIDENCE = True
CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid"
# Auto-match face size filtering
# Minimum face size as percentage of image area (0.5% = 0.005)
# Faces smaller than this are excluded from auto-match to avoid generic encodings
MIN_AUTO_MATCH_FACE_SIZE_RATIO = 0.005 # 0.5% of image area

View File

@ -42,6 +42,11 @@ class Photo(Base):
processed = Column(Boolean, default=False, nullable=False, index=True)
file_hash = Column(Text, nullable=True, index=True) # Nullable to support existing photos without hashes
media_type = Column(Text, default="image", nullable=False, index=True) # "image" or "video"
# Browser-safe playback (H.264/AAC); see web_video_service / migrations.
web_playable_path = Column(Text, nullable=True)
web_transcode_status = Column(Text, default="none", nullable=False)
web_transcode_error = Column(Text, nullable=True)
web_transcode_job_id = Column(Text, nullable=True)
faces = relationship("Face", back_populates="photo", cascade="all, delete-orphan")
photo_tags = relationship(

View File

@ -20,8 +20,12 @@ def get_database_url() -> str:
db_url = os.getenv("DATABASE_URL")
if db_url:
return db_url
# Default to PostgreSQL for development
return "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag"
# Default to PostgreSQL for development (without password - must be set via env var)
# This ensures no hardcoded passwords in the codebase
raise ValueError(
"DATABASE_URL environment variable not set. "
"Please set DATABASE_URL in your .env file or environment."
)
def get_auth_database_url() -> str:

View File

@ -89,6 +89,7 @@ class SimilarFaceItem(BaseModel):
quality_score: float
filename: str
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
debug_info: Optional[dict] = Field(None, description="Debug information (encoding stats) when debug mode is enabled")
class SimilarFacesResponse(BaseModel):
@ -98,6 +99,7 @@ class SimilarFacesResponse(BaseModel):
base_face_id: int
items: list[SimilarFaceItem]
debug_info: Optional[dict] = Field(None, description="Debug information (base face encoding stats) when debug mode is enabled")
class BatchSimilarityRequest(BaseModel):
@ -212,9 +214,10 @@ class AutoMatchRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
tolerance: float = Field(0.6, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
tolerance: float = Field(0.5, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
auto_accept: bool = Field(False, description="Enable automatic acceptance of matching faces")
auto_accept_threshold: float = Field(70.0, ge=0.0, le=100.0, description="Similarity threshold for auto-acceptance (0-100%)")
use_distance_based_thresholds: bool = Field(False, description="Use distance-based confidence thresholds (stricter for borderline distances)")
class AutoMatchFaceItem(BaseModel):

View File

@ -91,3 +91,20 @@ class BulkDeletePhotosResponse(BaseModel):
description="Photo IDs that were requested but not found",
)
class DirectoryItem(BaseModel):
"""Directory item (file or folder) in a directory listing."""
name: str = Field(..., description="Name of the item")
path: str = Field(..., description="Full absolute path to the item")
is_directory: bool = Field(..., description="Whether this is a directory")
is_file: bool = Field(..., description="Whether this is a file")
class BrowseDirectoryResponse(BaseModel):
"""Response for directory browsing."""
current_path: str = Field(..., description="Current directory path")
parent_path: Optional[str] = Field(None, description="Parent directory path (None if at root)")
items: List[DirectoryItem] = Field(..., description="List of items in the directory")

View File

@ -32,6 +32,7 @@ class PhotoSearchResult(BaseModel):
date_taken: Optional[date] = None
date_added: date
processed: bool
media_type: Optional[str] = "image" # "image" or "video"
person_name: Optional[str] = None # For name search
tags: List[str] = Field(default_factory=list) # All tags for the photo
has_faces: bool = False

View File

@ -88,6 +88,7 @@ class PhotoWithTagsItem(BaseModel):
unidentified_face_count: int # Count of faces with person_id IS NULL
tags: str # Comma-separated tags string (matching desktop)
people_names: str = "" # Comma-separated people names string
media_type: str = "image" # "image" or "video"
class PhotosWithTagsResponse(BaseModel):

View File

@ -80,6 +80,20 @@ class IdentifyVideoResponse(BaseModel):
message: str
class WebPlaybackPrepareResponse(BaseModel):
"""Response after requesting browser-safe playback preparation."""
status: str
message: str
class WebPlaybackStatusResponse(BaseModel):
"""Transcode / readiness status for web playback."""
status: str
error: Optional[str] = None
class RemoveVideoPersonResponse(BaseModel):
"""Response for removing a person from a video."""

View File

@ -6,6 +6,7 @@ import json
import os
import tempfile
import time
from pathlib import Path
from typing import Callable, Optional, Tuple, List, Dict
from datetime import date
@ -14,11 +15,17 @@ from PIL import Image
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, case
try:
from deepface import DeepFace
DEEPFACE_AVAILABLE = True
except ImportError:
# Skip DeepFace import during tests to avoid illegal instruction errors
if os.getenv("SKIP_DEEPFACE_IN_TESTS") == "1":
DEEPFACE_AVAILABLE = False
DeepFace = None
else:
try:
from deepface import DeepFace
DEEPFACE_AVAILABLE = True
except ImportError:
DEEPFACE_AVAILABLE = False
DeepFace = None
from backend.config import (
CONFIDENCE_CALIBRATION_METHOD,
@ -28,6 +35,7 @@ from backend.config import (
MAX_FACE_SIZE,
MIN_FACE_CONFIDENCE,
MIN_FACE_SIZE,
MIN_AUTO_MATCH_FACE_SIZE_RATIO,
USE_CALIBRATED_CONFIDENCE,
)
from src.utils.exif_utils import EXIFOrientationHandler
@ -471,9 +479,14 @@ def process_photo_faces(
return 0, 0
# Load image for quality calculation
# Use context manager to ensure image is closed properly to free memory
image = Image.open(photo_path)
image_np = np.array(image)
image_width, image_height = image.size
try:
image_np = np.array(image)
image_width, image_height = image.size
finally:
# Explicitly close image to free memory immediately
image.close()
# Count total faces from DeepFace
faces_detected = len(results)
@ -515,7 +528,9 @@ def process_photo_faces(
_print_with_stderr(f"[FaceService] Debug - face_confidence value: {face_confidence}")
_print_with_stderr(f"[FaceService] Debug - result['face_confidence'] exists: {'face_confidence' in result}")
encoding = np.array(result['embedding'])
# DeepFace returns float32 embeddings, but we store as float64 for consistency
# Convert to float64 explicitly to match how we read them back
encoding = np.array(result['embedding'], dtype=np.float64)
# Convert to location format (JSON string like desktop version)
location = {
@ -616,17 +631,21 @@ def process_photo_faces(
if face_width is None:
face_width = matched_pose_face.get('face_width')
pose_mode = PoseDetector.classify_pose_mode(
yaw_angle, pitch_angle, roll_angle, face_width
yaw_angle, pitch_angle, roll_angle, face_width, landmarks
)
else:
# Can't calculate yaw, use face_width
# Can't calculate yaw, use face_width and landmarks for single-eye detection
pose_mode = PoseDetector.classify_pose_mode(
yaw_angle, pitch_angle, roll_angle, face_width
yaw_angle, pitch_angle, roll_angle, face_width, landmarks
)
elif face_width is not None:
# No landmarks available, use face_width only
# Try to get landmarks from matched_pose_face if available
landmarks_for_classification = None
if matched_pose_face:
landmarks_for_classification = matched_pose_face.get('landmarks')
pose_mode = PoseDetector.classify_pose_mode(
yaw_angle, pitch_angle, roll_angle, face_width
yaw_angle, pitch_angle, roll_angle, face_width, landmarks_for_classification
)
else:
# No landmarks and no face_width, use default
@ -730,8 +749,19 @@ def process_photo_faces(
# If commit fails, rollback and log the error
db.rollback()
error_msg = str(commit_error)
error_str_lower = error_msg.lower()
# Check if it's a connection/disconnection error
is_connection_error = any(keyword in error_str_lower for keyword in [
'connection', 'disconnect', 'timeout', 'closed', 'lost',
'operationalerror', 'server closed', 'connection reset',
'connection pool', 'connection refused'
])
try:
_print_with_stderr(f"[FaceService] Failed to commit {faces_stored} faces for {photo.filename}: {error_msg}")
if is_connection_error:
_print_with_stderr(f"[FaceService] ⚠️ Database connection error detected - session may need refresh")
import traceback
traceback.print_exc()
except (BrokenPipeError, OSError):
@ -741,8 +771,7 @@ def process_photo_faces(
# This ensures the return value accurately reflects what was actually saved
faces_stored = 0
# Re-raise to be caught by outer exception handler in process_unprocessed_photos
# This allows the batch to continue processing other photos
# Re-raise with connection error flag so caller can refresh session
raise Exception(f"Database commit failed for {photo.filename}: {error_msg}")
# Mark photo as processed after handling faces (desktop parity)
@ -750,7 +779,18 @@ def process_photo_faces(
photo.processed = True
db.add(photo)
db.commit()
except Exception:
except Exception as mark_error:
# Log connection errors for debugging
error_str = str(mark_error).lower()
is_connection_error = any(keyword in error_str for keyword in [
'connection', 'disconnect', 'timeout', 'closed', 'lost',
'operationalerror', 'server closed', 'connection reset'
])
if is_connection_error:
try:
_print_with_stderr(f"[FaceService] ⚠️ Database connection error while marking photo as processed: {mark_error}")
except (BrokenPipeError, OSError):
pass
db.rollback()
# Log summary
@ -1253,6 +1293,26 @@ def process_unprocessed_photos(
update_progress(0, total, f"Starting face detection on {total} photos...", 0, 0)
for idx, photo in enumerate(unprocessed_photos, 1):
# Periodic database health check every 10 photos to catch connection issues early
if idx > 1 and idx % 10 == 0:
try:
from sqlalchemy import text
db.execute(text("SELECT 1"))
db.commit()
except Exception as health_check_error:
# Database connection is stale - this will be caught and handled below
error_str = str(health_check_error).lower()
is_connection_error = any(keyword in error_str for keyword in [
'connection', 'disconnect', 'timeout', 'closed', 'lost',
'operationalerror', 'server closed', 'connection reset'
])
if is_connection_error:
try:
print(f"[FaceService] ⚠️ Database health check failed at photo {idx}/{total}: {health_check_error}")
print(f"[FaceService] Session may need refresh - will be handled by error handler")
except (BrokenPipeError, OSError):
pass
# Check for cancellation BEFORE starting each photo
# This is the primary cancellation point - we stop before starting a new photo
if check_cancelled():
@ -1379,6 +1439,14 @@ def process_unprocessed_photos(
except (BrokenPipeError, OSError):
pass
# Check if it's a database connection error
error_str = str(e).lower()
is_db_connection_error = any(keyword in error_str for keyword in [
'connection', 'disconnect', 'timeout', 'closed', 'lost',
'operationalerror', 'database', 'server closed', 'connection reset',
'connection pool', 'connection refused'
])
# Refresh database session after error to ensure it's in a good state
# This prevents session state issues from affecting subsequent photos
# Note: process_photo_faces already does db.rollback(), but we ensure
@ -1388,6 +1456,23 @@ def process_unprocessed_photos(
db.rollback()
# Expire the current photo object to clear any stale state
db.expire(photo)
# If it's a connection error, try to refresh the session
if is_db_connection_error:
try:
# Test if session is still alive
from sqlalchemy import text
db.execute(text("SELECT 1"))
db.commit()
except Exception:
# Session is dead - need to get a new one from the caller
# We can't create a new SessionLocal here, so we'll raise a special exception
try:
print(f"[FaceService] ⚠️ Database session is dead after connection error - caller should refresh session")
except (BrokenPipeError, OSError):
pass
# Re-raise with a flag that indicates session needs refresh
raise Exception(f"Database connection lost - session needs refresh: {str(e)}")
except Exception as session_error:
# If session refresh fails, log but don't fail the batch
try:
@ -1592,6 +1677,47 @@ def list_unidentified_faces(
return items, total
def load_face_encoding(encoding_bytes: bytes) -> np.ndarray:
"""Load face encoding from bytes, auto-detecting dtype (float32 or float64).
ArcFace encodings are 512 dimensions:
- float32: 512 * 4 bytes = 2048 bytes
- float64: 512 * 8 bytes = 4096 bytes
Args:
encoding_bytes: Raw encoding bytes from database
Returns:
numpy array of encoding (always float64 for consistency)
"""
encoding_size = len(encoding_bytes)
# Auto-detect dtype based on size
if encoding_size == 2048:
# float32 encoding (old format)
encoding = np.frombuffer(encoding_bytes, dtype=np.float32)
# Convert to float64 for consistency
return encoding.astype(np.float64)
elif encoding_size == 4096:
# float64 encoding (new format)
return np.frombuffer(encoding_bytes, dtype=np.float64)
else:
# Unexpected size - try float64 first, fallback to float32
# This handles edge cases or future changes
try:
encoding = np.frombuffer(encoding_bytes, dtype=np.float64)
if len(encoding) == 512:
return encoding
except:
pass
# Fallback to float32
encoding = np.frombuffer(encoding_bytes, dtype=np.float32)
if len(encoding) == 512:
return encoding.astype(np.float64)
else:
raise ValueError(f"Unexpected encoding size: {encoding_size} bytes (expected 2048 or 4096)")
def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> float:
"""Calculate cosine distance between two face encodings, matching desktop exactly.
@ -1624,7 +1750,6 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
# Normalize encodings (matching desktop exactly)
norm1 = np.linalg.norm(enc1)
norm2 = np.linalg.norm(enc2)
if norm1 == 0 or norm2 == 0:
return 2.0
@ -1647,6 +1772,32 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
return 2.0 # Maximum distance on error
def get_distance_based_min_confidence(distance: float) -> float:
"""Get minimum confidence threshold based on distance.
For borderline distances, require higher confidence to reduce false positives.
This is used only when use_distance_based_thresholds=True (e.g., in auto-match).
Args:
distance: Cosine distance between faces (0 = identical, 2 = opposite)
Returns:
Minimum confidence percentage (0-100) required for this distance
"""
if distance <= 0.15:
# Very close matches: standard threshold
return 50.0
elif distance <= 0.20:
# Borderline matches: require higher confidence
return 70.0
elif distance <= 0.25:
# Near threshold: require very high confidence
return 85.0
else:
# Far matches: require extremely high confidence
return 95.0
def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) -> float:
"""Calculate adaptive tolerance based on face quality, matching desktop exactly."""
# Start with base tolerance
@ -1657,7 +1808,10 @@ def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) ->
tolerance *= quality_factor
# Ensure tolerance stays within reasonable bounds for DeepFace
return max(0.2, min(0.6, tolerance))
# Allow tolerance down to 0.0 (user can set very strict matching)
# Allow tolerance up to 1.0 (matching API validation range)
# The quality factor can increase tolerance up to 1.1x, so cap at 1.0 to stay within API limits
return max(0.0, min(1.0, tolerance))
def calibrate_confidence(distance: float, tolerance: float = None) -> float:
@ -1691,27 +1845,34 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
else: # "empirical" - default method (matching desktop exactly)
# Empirical calibration parameters for DeepFace ArcFace model
# These are derived from analysis of distance distributions for matching/non-matching pairs
# Moderate calibration: stricter than original but not too strict
# For very close distances (< 0.12): very high confidence
if distance <= 0.12:
# Very close matches: exponential decay from 100%
confidence = 100 * np.exp(-distance * 2.8)
return min(100, max(92, confidence))
# For distances well below threshold: high confidence
if distance <= tolerance * 0.5:
# Very close matches: exponential decay from 100%
confidence = 100 * np.exp(-distance * 2.5)
return min(100, max(95, confidence))
elif distance <= tolerance * 0.5:
# Close matches: exponential decay
confidence = 100 * np.exp(-distance * 2.6)
return min(92, max(82, confidence))
# For distances near threshold: moderate confidence
elif distance <= tolerance:
# Near-threshold matches: sigmoid-like curve
# Maps distance to probability based on empirical data
normalized_distance = (distance - tolerance * 0.5) / (tolerance * 0.5)
confidence = 95 - (normalized_distance * 40) # 95% to 55% range
return max(55, min(95, confidence))
confidence = 82 - (normalized_distance * 32) # 82% to 50% range
return max(50, min(82, confidence))
# For distances above threshold: low confidence
elif distance <= tolerance * 1.5:
# Above threshold but not too far: rapid decay
normalized_distance = (distance - tolerance) / (tolerance * 0.5)
confidence = 55 - (normalized_distance * 35) # 55% to 20% range
return max(20, min(55, confidence))
confidence = 50 - (normalized_distance * 30) # 50% to 20% range
return max(20, min(50, confidence))
# For very large distances: very low confidence
else:
@ -1720,6 +1881,46 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
return max(1, min(20, confidence))
def _calculate_face_size_ratio(face: Face, photo: Photo) -> float:
"""Calculate face size as ratio of image area.
Args:
face: Face model with location
photo: Photo model (needed for path to load image dimensions)
Returns:
Face size ratio (0.0-1.0), or 0.0 if cannot calculate
"""
try:
import json
from PIL import Image
# Parse location
location = json.loads(face.location) if isinstance(face.location, str) else face.location
face_w = location.get('w', 0)
face_h = location.get('h', 0)
face_area = face_w * face_h
if face_area == 0:
return 0.0
# Load image to get dimensions
photo_path = Path(photo.path)
if not photo_path.exists():
return 0.0
img = Image.open(photo_path)
img_width, img_height = img.size
image_area = img_width * img_height
if image_area == 0:
return 0.0
return face_area / image_area
except Exception:
return 0.0
def _is_acceptable_pose_for_auto_match(pose_mode: str) -> bool:
"""Check if pose_mode is acceptable for auto-match (frontal or tilted, but not profile).
@ -1759,10 +1960,14 @@ def find_similar_faces(
db: Session,
face_id: int,
limit: int = 20000, # Very high default limit - effectively unlimited
tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop
tolerance: float = 0.5, # DEFAULT_FACE_TOLERANCE
filter_frontal_only: bool = False, # New: Only return frontal or tilted faces (not profile)
include_excluded: bool = False, # Include excluded faces in results
) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct)
filter_small_faces: bool = False, # Filter out small faces (for auto-match)
min_face_size_ratio: float = 0.005, # Minimum face size ratio (0.5% of image)
debug: bool = False, # Include debug information (encoding stats)
use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds (for auto-match)
) -> List[Tuple[Face, float, float, dict | None]]: # Returns (face, distance, confidence_pct, debug_info)
"""Find similar faces matching desktop logic exactly.
Desktop flow:
@ -1789,32 +1994,48 @@ def find_similar_faces(
base: Face = db.query(Face).filter(Face.id == face_id).first()
if not base:
return []
# Load base encoding - desktop uses float64, ArcFace has 512 dimensions
# Stored as float64: 512 * 8 bytes = 4096 bytes
base_enc = np.frombuffer(base.encoding, dtype=np.float64)
# Load base encoding - auto-detect dtype (supports both float32 and float64)
base_enc = load_face_encoding(base.encoding)
base_enc = base_enc.copy() # Make a copy to avoid buffer issues
# Desktop uses 0.5 as default quality for target face (hardcoded, matching desktop exactly)
# Desktop: target_quality = 0.5 # Default quality for target face
base_quality = 0.5
# Use actual quality score of the reference face, defaulting to 0.5 if not set
# This ensures adaptive tolerance is calculated correctly based on the actual face quality
base_quality = float(base.quality_score) if base.quality_score is not None else 0.5
# Desktop: get ALL faces from database (matching get_all_face_encodings)
# Desktop find_similar_faces gets ALL faces, doesn't filter by photo_id
# Get all faces except itself, with photo loaded
# However, for auto-match, we should exclude faces from the same photo to avoid
# duplicate detections of the same face (same encoding stored multiple times)
# Get all faces except itself and faces from the same photo, with photo loaded
all_faces: List[Face] = (
db.query(Face)
.options(joinedload(Face.photo))
.filter(Face.id != face_id)
.filter(Face.photo_id != base.photo_id) # Exclude faces from same photo
.all()
)
matches: List[Tuple[Face, float, float]] = []
for f in all_faces:
# Load other encoding - desktop uses float64, ArcFace has 512 dimensions
other_enc = np.frombuffer(f.encoding, dtype=np.float64)
# Load other encoding - auto-detect dtype (supports both float32 and float64)
other_enc = load_face_encoding(f.encoding)
other_enc = other_enc.copy() # Make a copy to avoid buffer issues
# Calculate debug info if requested
debug_info = None
if debug:
debug_info = {
"encoding_length": len(other_enc),
"encoding_min": float(np.min(other_enc)),
"encoding_max": float(np.max(other_enc)),
"encoding_mean": float(np.mean(other_enc)),
"encoding_std": float(np.std(other_enc)),
"encoding_first_10": [float(x) for x in other_enc[:10].tolist()],
}
other_quality = float(f.quality_score) if f.quality_score is not None else 0.5
# Calculate adaptive tolerance based on both face qualities (matching desktop exactly)
@ -1829,14 +2050,22 @@ def find_similar_faces(
# Get photo info (desktop does this in find_similar_faces)
if f.photo:
# Calculate calibrated confidence (matching desktop _get_filtered_similar_faces)
confidence_pct = calibrate_confidence(distance, DEFAULT_FACE_TOLERANCE)
# Use the actual tolerance parameter, not the default
confidence_pct = calibrate_confidence(distance, tolerance)
# Desktop _get_filtered_similar_faces filters by:
# 1. person_id is None (unidentified)
# 2. confidence >= 40%
# 2. confidence >= 50% (increased from 40% to reduce false matches)
# OR confidence >= distance-based threshold if use_distance_based_thresholds=True
is_unidentified = f.person_id is None
if is_unidentified and confidence_pct >= 40:
# Calculate minimum confidence threshold
if use_distance_based_thresholds:
min_confidence = get_distance_based_min_confidence(distance)
else:
min_confidence = 50.0 # Standard threshold
if is_unidentified and confidence_pct >= min_confidence:
# Filter by excluded status if not including excluded faces
if not include_excluded and getattr(f, "excluded", False):
continue
@ -1845,9 +2074,16 @@ def find_similar_faces(
if filter_frontal_only and not _is_acceptable_pose_for_auto_match(f.pose_mode):
continue
# Filter by face size if requested (for auto-match)
if filter_small_faces:
if f.photo:
face_size_ratio = _calculate_face_size_ratio(f, f.photo)
if face_size_ratio < min_face_size_ratio:
continue # Skip small faces
# Return calibrated confidence percentage (matching desktop)
# Desktop displays confidence_pct directly from _get_calibrated_confidence
matches.append((f, distance, confidence_pct))
matches.append((f, distance, confidence_pct, debug_info))
# Sort by distance (lower is better) - matching desktop
matches.sort(key=lambda x: x[1])
@ -1860,6 +2096,7 @@ def calculate_batch_similarities(
db: Session,
face_ids: list[int],
min_confidence: float = 60.0,
tolerance: float = 0.6, # Use 0.6 for Identify People (more lenient for manual review)
) -> list[tuple[int, int, float, float]]:
"""Calculate similarities between N faces and all M faces in database.
@ -1909,7 +2146,7 @@ def calculate_batch_similarities(
for face in all_faces:
# Pre-load encoding as numpy array
all_encodings[face.id] = np.frombuffer(face.encoding, dtype=np.float64)
all_encodings[face.id] = load_face_encoding(face.encoding)
# Pre-cache quality score
all_qualities[face.id] = float(face.quality_score) if face.quality_score is not None else 0.5
@ -2005,8 +2242,9 @@ def calculate_batch_similarities(
def find_auto_match_matches(
db: Session,
tolerance: float = 0.6,
tolerance: float = 0.5,
filter_frontal_only: bool = False,
use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds
) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
"""Find auto-match matches for all identified people, matching desktop logic exactly.
@ -2099,16 +2337,30 @@ def find_auto_match_matches(
for person_id, reference_face, person_name in person_faces_list:
reference_face_id = reference_face.id
# TEMPORARILY DISABLED: Check if reference face is too small (exclude from auto-match)
# reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first()
# if reference_photo:
# ref_size_ratio = _calculate_face_size_ratio(reference_face, reference_photo)
# if ref_size_ratio < MIN_AUTO_MATCH_FACE_SIZE_RATIO:
# # Skip this person - reference face is too small
# continue
# Use find_similar_faces which matches desktop _get_filtered_similar_faces logic
# Desktop: similar_faces = self.face_processor._get_filtered_similar_faces(
# reference_face_id, tolerance, include_same_photo=False, face_status=None)
# This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance
# This filters by: person_id is None (unidentified), confidence >= 50% (increased from 40%), sorts by distance
# Auto-match always excludes excluded faces
similar_faces = find_similar_faces(
# TEMPORARILY DISABLED: filter_small_faces=True to exclude small match faces
similar_faces_with_debug = find_similar_faces(
db, reference_face_id, tolerance=tolerance,
filter_frontal_only=filter_frontal_only,
include_excluded=False # Auto-match always excludes excluded faces
include_excluded=False, # Auto-match always excludes excluded faces
filter_small_faces=False, # TEMPORARILY DISABLED: Exclude small faces from auto-match
min_face_size_ratio=MIN_AUTO_MATCH_FACE_SIZE_RATIO,
use_distance_based_thresholds=use_distance_based_thresholds # Use distance-based thresholds if enabled
)
# Strip debug_info for internal use
similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug]
if similar_faces:
results.append((person_id, reference_face_id, reference_face, similar_faces))
@ -2119,7 +2371,7 @@ def find_auto_match_matches(
def get_auto_match_people_list(
db: Session,
filter_frontal_only: bool = False,
tolerance: float = 0.6,
tolerance: float = 0.5,
) -> List[Tuple[int, Face, str, int]]:
"""Get list of people for auto-match (without matches) - fast initial load.
@ -2223,7 +2475,7 @@ def get_auto_match_people_list(
def get_auto_match_person_matches(
db: Session,
person_id: int,
tolerance: float = 0.6,
tolerance: float = 0.5,
filter_frontal_only: bool = False,
) -> List[Tuple[Face, float, float]]:
"""Get matches for a specific person - for lazy loading.
@ -2252,11 +2504,13 @@ def get_auto_match_person_matches(
# Find similar faces using existing function
# Auto-match always excludes excluded faces
similar_faces = find_similar_faces(
similar_faces_with_debug = find_similar_faces(
db, reference_face.id, tolerance=tolerance,
filter_frontal_only=filter_frontal_only,
include_excluded=False # Auto-match always excludes excluded faces
)
# Strip debug_info for internal use
similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug]
return similar_faces

View File

@ -58,31 +58,118 @@ def extract_exif_date(image_path: str) -> Optional[date]:
"""Extract date taken from photo EXIF data - returns Date (not DateTime) to match desktop schema.
Tries multiple methods to extract EXIF date:
1. PIL's getexif() (modern method)
2. PIL's _getexif() (deprecated but sometimes more reliable)
3. Access EXIF IFD directly if available
1. exifread library (most reliable for reading EXIF)
2. PIL's getexif() (modern method) - uses .get() for tag access
3. PIL's _getexif() (deprecated but sometimes more reliable)
4. Access EXIF IFD directly if available
Returns:
Date object or None if no valid EXIF date found
"""
import logging
logger = logging.getLogger(__name__)
# Try exifread library first (most reliable)
try:
import exifread
with open(image_path, 'rb') as f:
tags = exifread.process_file(f, details=False)
# Look for date tags in exifread format
# exifread uses tag names like 'EXIF DateTimeOriginal', 'Image DateTime', etc.
date_tag_names = [
'EXIF DateTimeOriginal', # When photo was taken (highest priority)
'EXIF DateTimeDigitized', # When photo was digitized
'Image DateTime', # File modification date
'EXIF DateTime', # Alternative format
]
for tag_name in date_tag_names:
if tag_name in tags:
date_str = str(tags[tag_name]).strip()
if date_str and date_str != "0000:00:00 00:00:00" and not date_str.startswith("0000:"):
try:
# exifread returns dates in format "YYYY:MM:DD HH:MM:SS"
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
extracted_date = dt.date()
if extracted_date <= date.today() and extracted_date >= date(1900, 1, 1):
logger.info(f"Successfully extracted date {extracted_date} from {tag_name} using exifread for {image_path}")
return extracted_date
except ValueError:
# Try alternative format
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
extracted_date = dt.date()
if extracted_date <= date.today() and extracted_date >= date(1900, 1, 1):
logger.info(f"Successfully extracted date {extracted_date} from {tag_name} using exifread for {image_path}")
return extracted_date
except ValueError:
continue
elif date_str:
logger.debug(f"Skipping invalid date string '{date_str}' from {tag_name} in {image_path}")
except ImportError:
logger.debug("exifread library not available, falling back to PIL")
except Exception as e:
logger.warning(f"exifread failed for {image_path}: {e}, trying PIL", exc_info=True)
# Log what tags exifread could see (if any)
try:
import exifread
with open(image_path, 'rb') as test_f:
test_tags = exifread.process_file(test_f, details=False)
if test_tags:
logger.warning(f"exifread found {len(test_tags)} tags but couldn't parse dates. Sample tags: {list(test_tags.keys())[:5]}")
except Exception:
pass
# Fallback to PIL methods
try:
with Image.open(image_path) as image:
exifdata = None
is_modern_api = False
# Try modern getexif() first
try:
exifdata = image.getexif()
except Exception:
pass
if exifdata and len(exifdata) > 0:
is_modern_api = True
logger.debug(f"Using modern getexif() API for {image_path}, found {len(exifdata)} EXIF tags")
except Exception as e:
logger.debug(f"Modern getexif() failed for {image_path}: {e}")
# If getexif() didn't work or returned empty, try deprecated _getexif()
if not exifdata or len(exifdata) == 0:
try:
if hasattr(image, '_getexif'):
exifdata = image._getexif()
except Exception:
pass
if exifdata:
logger.debug(f"Using deprecated _getexif() API for {image_path}, found {len(exifdata)} EXIF tags")
except Exception as e:
logger.debug(f"Deprecated _getexif() failed for {image_path}: {e}")
if not exifdata:
logger.warning(f"No EXIF data found in {image_path} - will fall back to file modification time")
# Try to open the file with exifread to see if it has EXIF at all
try:
import exifread
with open(image_path, 'rb') as test_f:
test_tags = exifread.process_file(test_f, details=False)
if test_tags:
logger.warning(f"File {image_path} has EXIF tags via exifread but PIL couldn't read them: {list(test_tags.keys())[:10]}")
else:
logger.warning(f"File {image_path} has no EXIF data at all")
except Exception:
pass
return None
# Debug: Log all available EXIF tags (only in debug mode to avoid spam)
if logger.isEnabledFor(logging.DEBUG):
try:
if hasattr(exifdata, 'items'):
all_tags = list(exifdata.items())[:20] # First 20 tags for debugging
logger.debug(f"Available EXIF tags in {image_path}: {all_tags}")
except Exception:
pass
# Look for date taken in EXIF tags
# Priority: DateTimeOriginal (when photo was taken) > DateTimeDigitized > DateTime (file modification)
date_tags = [
@ -91,69 +178,203 @@ def extract_exif_date(image_path: str) -> Optional[date]:
306, # DateTime - file modification date (lowest priority)
]
# Try direct access first
# Also try to find any date-like tags by iterating through all tags
# This helps catch dates that might be in different tag IDs
all_date_strings = []
try:
if hasattr(exifdata, 'items'):
for tag_id, value in exifdata.items():
if value and isinstance(value, (str, bytes)):
value_str = value.decode('utf-8', errors='ignore') if isinstance(value, bytes) else str(value)
# Check if it looks like a date string (YYYY:MM:DD or YYYY-MM-DD format)
if len(value_str) >= 10 and ('-' in value_str[:10] or ':' in value_str[:10]):
try:
# Try to parse it as a date
if ':' in value_str[:10]:
test_dt = datetime.strptime(value_str[:19], "%Y:%m:%d %H:%M:%S")
else:
test_dt = datetime.strptime(value_str[:19], "%Y-%m-%d %H:%M:%S")
all_date_strings.append((tag_id, value_str, test_dt.date()))
except (ValueError, IndexError):
pass
except Exception as e:
logger.debug(f"Error iterating through all EXIF tags in {image_path}: {e}")
# Try accessing tags - use multiple methods for compatibility
for tag_id in date_tags:
try:
if tag_id in exifdata:
date_str = exifdata[tag_id]
if date_str:
# Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
# Try multiple access methods for compatibility
date_str = None
if is_modern_api:
# Modern getexif() API - try multiple access methods
# The Exif object from getexif() supports dictionary-like access
try:
# Method 1: Try .get() method
if hasattr(exifdata, 'get'):
date_str = exifdata.get(tag_id)
else:
date_str = None
# Method 2: If .get() returned None, try direct access
if not date_str:
try:
# Exif objects support __getitem__ for tag access
date_str = exifdata[tag_id]
except (KeyError, TypeError, AttributeError):
pass
# Method 3: Try iterating through all tags
if not date_str:
try:
# Exif objects are iterable
for key, value in exifdata.items():
if key == tag_id:
date_str = value
break
except (AttributeError, TypeError):
pass
# Method 4: Try using ExifTags.TAGS to help identify tags
if not date_str:
try:
from PIL.ExifTags import TAGS
# Log what tags are available for debugging
if logger.isEnabledFor(logging.DEBUG):
available_tag_ids = list(exifdata.keys())[:10]
logger.debug(f"Available tag IDs in {image_path}: {available_tag_ids}")
for tid in available_tag_ids:
tag_name = TAGS.get(tid, f"Unknown({tid})")
logger.debug(f" Tag {tid} ({tag_name}): {exifdata.get(tid)}")
except (ImportError, AttributeError, TypeError):
pass
except Exception as e:
logger.debug(f"Error accessing tag {tag_id} with modern API: {e}")
date_str = None
else:
# Old _getexif() returns a dict-like object
if hasattr(exifdata, 'get'):
date_str = exifdata.get(tag_id)
elif hasattr(exifdata, '__getitem__'):
try:
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
if tag_id in exifdata:
date_str = exifdata[tag_id]
except (KeyError, TypeError):
pass
if date_str:
# Ensure date_str is a string, not bytes or other type
if isinstance(date_str, bytes):
date_str = date_str.decode('utf-8', errors='ignore')
elif not isinstance(date_str, str):
date_str = str(date_str)
# Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
try:
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
extracted_date = dt.date()
# Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
logger.debug(f"Invalid date {extracted_date} from tag {tag_id} in {image_path}")
continue # Skip invalid dates
logger.debug(f"Successfully extracted date {extracted_date} from tag {tag_id} in {image_path}")
return extracted_date
except ValueError:
# Try alternative format
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
extracted_date = dt.date()
# Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
logger.debug(f"Invalid date {extracted_date} from tag {tag_id} in {image_path}")
continue # Skip invalid dates
logger.debug(f"Successfully extracted date {extracted_date} from tag {tag_id} in {image_path}")
return extracted_date
except ValueError:
# Try alternative format
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
extracted_date = dt.date()
# Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
continue # Skip invalid dates
return extracted_date
except ValueError:
continue
except (KeyError, TypeError):
except ValueError as ve:
logger.debug(f"Failed to parse date string '{date_str}' from tag {tag_id} in {image_path}: {ve}")
continue
except (KeyError, TypeError, AttributeError) as e:
logger.debug(f"Error accessing tag {tag_id} in {image_path}: {e}")
continue
# If we found date strings by iterating, try them (prioritize DateTimeOriginal-like dates)
if all_date_strings:
# Sort by tag ID (lower IDs like 306, 36867, 36868 are date tags)
# Priority: DateTimeOriginal (36867) > DateTimeDigitized (36868) > DateTime (306) > others
all_date_strings.sort(key=lambda x: (
0 if x[0] == 36867 else # DateTimeOriginal first
1 if x[0] == 36868 else # DateTimeDigitized second
2 if x[0] == 306 else # DateTime third
3 # Other dates last
))
for tag_id, date_str, extracted_date in all_date_strings:
# Validate date
if extracted_date <= date.today() and extracted_date >= date(1900, 1, 1):
logger.info(f"Successfully extracted date {extracted_date} from tag {tag_id} (found by iteration) in {image_path}")
return extracted_date
# Try accessing EXIF IFD directly if available (for tags in EXIF IFD like DateTimeOriginal)
try:
if hasattr(exifdata, 'get_ifd'):
# EXIF IFD is at offset 0x8769
exif_ifd = exifdata.get_ifd(0x8769)
if exif_ifd:
logger.debug(f"Trying EXIF IFD for {image_path}")
for tag_id in date_tags:
if tag_id in exif_ifd:
date_str = exif_ifd[tag_id]
try:
# Try multiple access methods for IFD
date_str = None
if hasattr(exif_ifd, 'get'):
date_str = exif_ifd.get(tag_id)
elif hasattr(exif_ifd, '__getitem__'):
try:
if tag_id in exif_ifd:
date_str = exif_ifd[tag_id]
except (KeyError, TypeError):
pass
if date_str:
try:
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
extracted_date = dt.date()
# Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
continue # Skip invalid dates
continue
logger.debug(f"Successfully extracted date {extracted_date} from IFD tag {tag_id} in {image_path}")
return extracted_date
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
extracted_date = dt.date()
# Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
continue # Skip invalid dates
continue
logger.debug(f"Successfully extracted date {extracted_date} from IFD tag {tag_id} in {image_path}")
return extracted_date
except ValueError:
continue
except Exception:
pass
except (KeyError, TypeError, AttributeError):
continue
except Exception as e:
logger.debug(f"Error accessing EXIF IFD for {image_path}: {e}")
logger.debug(f"No valid date found in EXIF data for {image_path}")
except Exception as e:
# Log error for debugging (but don't fail the import)
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Failed to extract EXIF date from {image_path}: {e}")
logger.warning(f"Failed to extract EXIF date from {image_path}: {e}", exc_info=True)
# Try a diagnostic check with exifread to see what's available
try:
import exifread
with open(image_path, 'rb') as diag_f:
diag_tags = exifread.process_file(diag_f, details=False)
if diag_tags:
date_tags_found = [k for k in diag_tags.keys() if 'date' in k.lower() or 'time' in k.lower()]
logger.warning(f"Diagnostic: File {image_path} has {len(diag_tags)} EXIF tags. Date-related tags: {date_tags_found[:10]}")
else:
logger.warning(f"Diagnostic: File {image_path} has no EXIF tags at all")
except Exception as diag_e:
logger.debug(f"Diagnostic check failed: {diag_e}")
return None
@ -263,35 +484,102 @@ def extract_video_date(video_path: str) -> Optional[date]:
return None
def extract_photo_date(image_path: str) -> Optional[date]:
"""Extract date taken from photo with fallback to file modification time.
def extract_photo_date(image_path: str, is_uploaded_file: bool = False) -> Optional[date]:
"""Extract date taken from photo with fallback to file modification time, then creation time.
Tries in order:
1. EXIF date tags (DateTimeOriginal, DateTimeDigitized, DateTime)
2. File modification time (as fallback)
2. File modification time (as fallback if EXIF fails)
3. File creation time (as final fallback if modification time doesn't exist)
Args:
image_path: Path to the image file
is_uploaded_file: If True, be more lenient about file modification times
(uploaded files have recent modification times but may have valid EXIF)
Returns:
Date object or None if no date can be determined
"""
import logging
import stat
logger = logging.getLogger(__name__)
# First try EXIF date extraction
date_taken = extract_exif_date(image_path)
if date_taken:
logger.info(f"Successfully extracted EXIF date {date_taken} from {image_path}")
return date_taken
# Fallback to file modification time
# EXIF extraction failed - try file modification time
logger.warning(f"EXIF date extraction failed for {image_path}, trying file modification time")
try:
if os.path.exists(image_path):
mtime = os.path.getmtime(image_path)
mtime_date = datetime.fromtimestamp(mtime).date()
# Validate date before returning (reject future dates)
if mtime_date > date.today() or mtime_date < date(1900, 1, 1):
return None # Skip invalid dates
return mtime_date
# Try modification time first
try:
mtime = os.path.getmtime(image_path)
mtime_date = datetime.fromtimestamp(mtime).date()
today = date.today()
# Reject future dates and dates that are too recent (likely copy dates)
# If modification time is within the last 7 days, it's probably a copy date, not the original photo date
# BUT: for uploaded files, we should be more lenient since EXIF might have failed for other reasons
days_ago = (today - mtime_date).days
if mtime_date <= today and mtime_date >= date(1900, 1, 1):
if days_ago <= 7 and not is_uploaded_file:
# Modification time is too recent - likely a copy date, skip it
# (unless it's an uploaded file where we should trust EXIF extraction failure)
logger.debug(f"File modification time {mtime_date} is too recent (likely copy date) for {image_path}, trying creation time")
else:
# Modification time is old enough to be a real photo date, OR it's an uploaded file
if is_uploaded_file:
logger.info(f"Using file modification time {mtime_date} for uploaded file {image_path} (EXIF extraction failed)")
else:
logger.info(f"Using file modification time {mtime_date} for {image_path}")
return mtime_date
else:
logger.debug(f"File modification time {mtime_date} is invalid for {image_path}, trying creation time")
except (OSError, ValueError) as e:
logger.debug(f"Failed to get modification time from {image_path}: {e}, trying creation time")
# Fallback to creation time (birthtime on some systems, ctime on others)
try:
# Try to get creation time (birthtime on macOS/BSD, ctime on Linux as fallback)
stat_info = os.stat(image_path)
# On Linux, ctime is change time (not creation), but it's the best we have
# On macOS/BSD, st_birthtime exists
if hasattr(stat_info, 'st_birthtime'):
# macOS/BSD - use birthtime (actual creation time)
ctime = stat_info.st_birthtime
else:
# Linux - use ctime (change time, closest to creation we can get)
ctime = stat_info.st_ctime
ctime_date = datetime.fromtimestamp(ctime).date()
today = date.today()
# Validate date before returning (reject future dates and recent copy dates)
# BUT: for uploaded files, be more lenient since EXIF might have failed for other reasons
days_ago = (today - ctime_date).days
if ctime_date <= today and ctime_date >= date(1900, 1, 1):
if days_ago <= 7 and not is_uploaded_file:
# Creation time is too recent - likely a copy date, reject it
# (unless it's an uploaded file where we should trust EXIF extraction failure)
logger.warning(f"File creation time {ctime_date} is too recent (likely copy date) for {image_path}, cannot determine photo date")
return None
else:
# Creation time is old enough to be a real photo date, OR it's an uploaded file
if is_uploaded_file:
logger.info(f"Using file creation/change time {ctime_date} for uploaded file {image_path} (EXIF extraction failed)")
else:
logger.info(f"Using file creation/change time {ctime_date} for {image_path}")
return ctime_date
else:
logger.warning(f"File creation time {ctime_date} is invalid for {image_path}")
except (OSError, ValueError, AttributeError) as e:
logger.error(f"Failed to get creation time from {image_path}: {e}")
except Exception as e:
# Log error for debugging (but don't fail the import)
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Failed to get file modification time from {image_path}: {e}")
logger.error(f"Failed to get file timestamps from {image_path}: {e}")
return None
@ -328,7 +616,7 @@ def find_photos_in_folder(folder_path: str, recursive: bool = True) -> list[str]
def import_photo_from_path(
db: Session, photo_path: str, update_progress: Optional[Callable[[int, int, str], None]] = None
db: Session, photo_path: str, update_progress: Optional[Callable[[int, int, str], None]] = None, is_uploaded_file: bool = False, file_last_modified: Optional[date] = None, browser_exif_date: Optional[date] = None
) -> Tuple[Optional[Photo], bool]:
"""Import a single photo or video from file path into database.
@ -363,7 +651,7 @@ def import_photo_from_path(
if media_type == "video":
date_taken = extract_video_date(photo_path)
else:
date_taken = extract_photo_date(photo_path)
date_taken = extract_photo_date(photo_path, is_uploaded_file=is_uploaded_file)
# Validate date_taken before setting
date_taken = validate_date_taken(date_taken)
if date_taken:
@ -385,7 +673,7 @@ def import_photo_from_path(
if media_type == "video":
date_taken = extract_video_date(photo_path)
else:
date_taken = extract_photo_date(photo_path)
date_taken = extract_photo_date(photo_path, is_uploaded_file=is_uploaded_file)
# Validate date_taken before setting
date_taken = validate_date_taken(date_taken)
if date_taken:
@ -394,15 +682,35 @@ def import_photo_from_path(
db.refresh(existing_by_path)
return existing_by_path, False
# Extract date taken with fallback to file modification time
# Extract date taken with priority: browser EXIF > server EXIF > browser file modification time > server file modification time
import logging
logger = logging.getLogger(__name__)
if media_type == "video":
date_taken = extract_video_date(photo_path)
else:
date_taken = extract_photo_date(photo_path)
# Priority 1: Use browser-extracted EXIF date (most reliable - extracted from original file before upload)
if browser_exif_date:
logger.info(f"[DATE_EXTRACTION] Using browser-extracted EXIF date {browser_exif_date} for {photo_path}")
date_taken = browser_exif_date
# Priority 2: Use browser-captured file modification time (from original file before upload)
# This MUST come before server-side extraction to avoid using the server file's modification time (which is today)
elif file_last_modified:
logger.info(f"[DATE_EXTRACTION] Using file's original modification date {file_last_modified} from browser metadata for {photo_path}")
date_taken = file_last_modified
else:
logger.debug(f"[DATE_EXTRACTION] No browser metadata for {photo_path}, trying server EXIF extraction")
# Priority 3: Try to extract EXIF from the uploaded file on server
date_taken = extract_photo_date(photo_path, is_uploaded_file=is_uploaded_file)
if not date_taken:
logger.warning(f"[DATE_EXTRACTION] No date found for {photo_path} - browser_exif_date={browser_exif_date}, file_last_modified={file_last_modified}")
# Validate date_taken - ensure it's a valid date object or None
# This prevents corrupted date data from being saved
logger.debug(f"[DATE_EXTRACTION] Before validation: date_taken={date_taken} for {photo_path}")
date_taken = validate_date_taken(date_taken)
logger.info(f"[DATE_EXTRACTION] After validation: date_taken={date_taken} for {photo_path}")
# For videos, mark as processed immediately (we don't process videos for faces)
# For images, start as unprocessed

View File

@ -119,6 +119,34 @@ def process_faces_task(
total_faces_detected = 0
total_faces_stored = 0
def refresh_db_session():
"""Refresh database session if it becomes stale or disconnected.
This prevents crashes when the database connection is lost during long-running
processing tasks. Closes the old session and creates a new one.
"""
nonlocal db
try:
# Test if the session is still alive by executing a simple query
from sqlalchemy import text
db.execute(text("SELECT 1"))
db.commit() # Ensure transaction is clean
except Exception as e:
# Session is stale or disconnected - create a new one
try:
print(f"[Task] Database session disconnected, refreshing... Error: {e}")
except (BrokenPipeError, OSError):
pass
try:
db.close()
except Exception:
pass
db = SessionLocal()
try:
print(f"[Task] Database session refreshed")
except (BrokenPipeError, OSError):
pass
try:
def update_progress(
processed: int,
@ -181,6 +209,9 @@ def process_faces_task(
# Process faces
# Wrap in try-except to ensure we preserve progress even if process_unprocessed_photos fails
try:
# Refresh session before starting processing to ensure it's healthy
refresh_db_session()
photos_processed, total_faces_detected, total_faces_stored = (
process_unprocessed_photos(
db,
@ -191,6 +222,27 @@ def process_faces_task(
)
)
except Exception as e:
# Check if it's a database connection error
error_str = str(e).lower()
is_db_error = any(keyword in error_str for keyword in [
'connection', 'disconnect', 'timeout', 'closed', 'lost',
'operationalerror', 'database', 'server closed', 'connection reset',
'connection pool', 'connection refused', 'session needs refresh'
])
if is_db_error:
# Try to refresh the session - this helps if the error is recoverable
# but we don't retry the entire batch to avoid reprocessing photos
try:
print(f"[Task] Database error detected, attempting to refresh session: {e}")
refresh_db_session()
print(f"[Task] Session refreshed - job will fail gracefully. Restart job to continue processing remaining photos.")
except Exception as refresh_error:
try:
print(f"[Task] Failed to refresh database session: {refresh_error}")
except (BrokenPipeError, OSError):
pass
# If process_unprocessed_photos fails, preserve any progress made
# and re-raise so the outer handler can log it properly
try:
@ -293,3 +345,78 @@ def process_faces_task(
finally:
db.close()
def transcode_video_for_web_task(photo_id: int) -> dict:
"""Background job: build browser-safe MP4 (or mark original as ready if H.264/AAC)."""
import os
import traceback
from backend.db.models import Photo
from backend.db.session import SessionLocal
from backend.services.web_video_service import (
derived_mp4_path,
expire_web_playable_if_stale,
probe_browser_safe_video,
run_ffmpeg_web_transcode,
web_playback_is_ready,
)
db = SessionLocal()
try:
photo = (
db.query(Photo)
.filter(Photo.id == photo_id)
.with_for_update()
.first()
)
if not photo or photo.media_type != "video":
return {"ok": False, "error": "not_found"}
expire_web_playable_if_stale(photo)
if web_playback_is_ready(photo):
db.commit()
return {"ok": True, "skipped": True}
photo.web_transcode_status = "running"
photo.web_transcode_error = None
db.commit()
if not os.path.isfile(photo.path):
raise RuntimeError("Source video file not found on disk")
if probe_browser_safe_video(photo.path):
photo.web_playable_path = photo.path
photo.web_transcode_status = "ready"
photo.web_transcode_error = None
db.commit()
return {"ok": True, "native": True}
dst = derived_mp4_path(photo_id)
run_ffmpeg_web_transcode(photo.path, dst)
photo.web_playable_path = str(dst)
photo.web_transcode_status = "ready"
photo.web_transcode_error = None
db.commit()
return {"ok": True, "transcoded": True}
except Exception as e:
try:
db.rollback()
except Exception:
pass
try:
print(f"[Task] web transcode failed for photo {photo_id}: {e}")
traceback.print_exc()
except (BrokenPipeError, OSError):
pass
try:
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if photo:
photo.web_transcode_status = "failed"
photo.web_transcode_error = str(e)[:2000]
db.commit()
except Exception:
pass
return {"ok": False, "error": str(e)}
finally:
db.close()

View File

@ -10,9 +10,11 @@ from typing import Optional
from PIL import Image
# Cache directory for thumbnails (relative to project root)
# Will be created in the same directory as the database
THUMBNAIL_CACHE_DIR = Path(__file__).parent.parent.parent.parent / "data" / "thumbnails"
# Cache directory for thumbnails (relative to project root).
# NOTE: This file lives at: <repo>/backend/services/thumbnail_service.py
# So project root is 2 levels up from: <repo>/backend/services/
PROJECT_ROOT = Path(__file__).resolve().parents[2]
THUMBNAIL_CACHE_DIR = PROJECT_ROOT / "data" / "thumbnails"
THUMBNAIL_SIZE = (320, 240) # Width, Height
THUMBNAIL_QUALITY = 85 # JPEG quality

View File

@ -0,0 +1,292 @@
"""Browser-safe video playback: transcode cache with TTL and ffprobe shortcuts."""
from __future__ import annotations
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Any, Optional
from fastapi import Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from sqlalchemy.orm import Session
from backend.db.models import Photo
from backend.settings import get_web_video_cache_directory
# Transcoded files older than this are deleted and must be regenerated.
WEB_PLAYBACK_TTL_SECONDS = 86400 # 24 hours
def derived_mp4_path(photo_id: int) -> Path:
"""Absolute path for cached transcoded MP4."""
return get_web_video_cache_directory() / f"{photo_id}.mp4"
def _file_within_ttl(path: Path) -> bool:
if not path.is_file():
return False
age = time.time() - path.stat().st_mtime
return age < WEB_PLAYBACK_TTL_SECONDS
def expire_web_playable_if_stale(photo: Photo) -> None:
"""Drop derived cache row + file when past TTL (does not delete originals)."""
if photo.media_type != "video":
return
cached = photo.web_playable_path
if not cached:
return
if cached == photo.path:
if not os.path.isfile(photo.path):
photo.web_playable_path = None
photo.web_transcode_status = "none"
photo.web_transcode_error = None
return
p = Path(cached)
if not p.is_file():
photo.web_playable_path = None
photo.web_transcode_status = "none"
photo.web_transcode_error = None
return
if not _file_within_ttl(p):
try:
p.unlink(missing_ok=True)
except OSError:
pass
photo.web_playable_path = None
photo.web_transcode_status = "none"
photo.web_transcode_error = None
def probe_browser_safe_video(video_path: str) -> bool:
"""True if streams look playable in common HTML5 video (H.264 + yuv420p + AAC)."""
if not os.path.isfile(video_path):
return False
try:
out = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name,pix_fmt",
"-of",
"json",
video_path,
],
capture_output=True,
text=True,
timeout=60,
check=False,
)
if out.returncode != 0:
return False
data: dict[str, Any] = json.loads(out.stdout or "{}")
streams = data.get("streams") or []
vcodec: Optional[str] = None
pix: Optional[str] = None
has_aac = False
has_audio = False
for s in streams:
if s.get("codec_type") == "video":
vcodec = (s.get("codec_name") or "").lower()
pix = (s.get("pix_fmt") or "").lower()
elif s.get("codec_type") == "audio":
has_audio = True
if (s.get("codec_name") or "").lower() in ("aac", "mp4a"):
has_aac = True
if vcodec != "h264":
return False
if pix not in ("yuv420p", "yuvj420p"):
return False
if has_audio and not has_aac:
return False
return True
except (OSError, json.JSONDecodeError, subprocess.TimeoutExpired):
return False
def run_ffmpeg_web_transcode(src: str, dst: Path) -> None:
"""Transcode to H.264/AAC MP4 suitable for browsers."""
dst.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg",
"-y",
"-i",
src,
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"23",
"-preset",
"veryfast",
"-c:a",
"aac",
"-b:a",
"160k",
"-movflags",
"+faststart",
str(dst),
]
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=7200,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "")[-2000:]
raise RuntimeError(f"ffmpeg failed (code {proc.returncode}): {err}")
def resolve_valid_playable_path(photo: Photo) -> Optional[Path]:
"""Return filesystem path to serve if ready and valid, else None."""
expire_web_playable_if_stale(photo)
p = photo.web_playable_path
if not p or photo.web_transcode_status != "ready":
return None
if p == photo.path:
path = Path(p)
return path if path.is_file() else None
path = Path(p).resolve()
try:
path.relative_to(get_web_video_cache_directory().resolve())
except ValueError:
return None
if path.is_file() and _file_within_ttl(path):
return path
return None
def web_playback_is_ready(photo: Photo) -> bool:
return resolve_valid_playable_path(photo) is not None
def _parse_range_header(range_header: str | None, file_size: int) -> Optional[tuple[int, int]]:
if not range_header:
return None
if not range_header.startswith("bytes="):
return None
range_part = range_header[6:]
parts = range_part.split("-", 1)
start_str = parts[0].strip()
end_str = parts[1].strip() if len(parts) > 1 else ""
try:
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
except ValueError:
return None
if start < 0 or end >= file_size or start > end:
return None
return start, end
def stream_web_playable_file(video_path: Path, request: Request) -> Response | FileResponse:
"""Range-capable streaming for MP4 (same behavior as legacy video endpoint)."""
file_size = video_path.stat().st_size
range_header = request.headers.get("range")
range_data = _parse_range_header(range_header, file_size)
media_type = "video/mp4"
if range_data:
start, end = range_data
chunk_size = end - start + 1
def generate_chunk():
with open(video_path, "rb") as handle:
handle.seek(start)
remaining = chunk_size
while remaining > 0:
chunk = handle.read(min(8192, remaining))
if not chunk:
break
yield chunk
remaining -= len(chunk)
return StreamingResponse(
generate_chunk(),
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Type": media_type,
"Content-Disposition": "inline",
"Cache-Control": f"public, max-age={WEB_PLAYBACK_TTL_SECONDS}",
},
media_type=media_type,
)
response = FileResponse(
str(video_path),
media_type=media_type,
)
response.headers["Content-Disposition"] = "inline"
response.headers["Accept-Ranges"] = "bytes"
response.headers["Cache-Control"] = f"public, max-age={WEB_PLAYBACK_TTL_SECONDS}"
return response
def prepare_web_playback(photo_id: int, db: Session, queue) -> dict[str, str]:
"""Enqueue or confirm web playback; caller must commit after."""
photo = (
db.query(Photo)
.filter(Photo.id == photo_id, Photo.media_type == "video")
.with_for_update()
.first()
)
if not photo:
return {"status": "not_found", "message": "Video not found"}
if not os.path.isfile(photo.path):
return {"status": "not_found", "message": "File missing on server"}
expire_web_playable_if_stale(photo)
db.flush()
if web_playback_is_ready(photo):
return {"status": "ready", "message": "Playback file available"}
if photo.web_transcode_status in ("queued", "running"):
return {"status": photo.web_transcode_status, "message": "Transcode in progress"}
if photo.web_transcode_status == "failed":
photo.web_transcode_status = "none"
photo.web_transcode_error = None
photo.web_transcode_job_id = None
job = queue.enqueue(
"backend.services.tasks.transcode_video_for_web_task",
photo_id,
job_timeout=7200,
result_ttl=120,
)
photo.web_transcode_status = "queued"
photo.web_transcode_job_id = job.get_id()
photo.web_transcode_error = None
return {"status": "queued", "message": "Transcode queued"}
def get_web_playback_status_dict(photo_id: int, db: Session) -> dict[str, Optional[str]]:
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo or photo.media_type != "video":
return {"status": "not_found", "error": None}
expire_web_playable_if_stale(photo)
db.commit()
db.refresh(photo)
if web_playback_is_ready(photo):
return {"status": "ready", "error": None}
err = photo.web_transcode_error
if photo.web_transcode_status == "failed":
return {"status": "failed", "error": err}
return {"status": photo.web_transcode_status, "error": err}

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import os
from pathlib import Path
APP_TITLE = "PunimTag Web API"
APP_VERSION = "0.1.0"
@ -11,3 +12,46 @@ APP_VERSION = "0.1.0"
PHOTO_STORAGE_DIR = os.getenv("PHOTO_STORAGE_DIR", "data/uploads")
def _project_root() -> Path:
"""Repository root (parent of ``backend/``)."""
return Path(__file__).resolve().parent.parent
def get_web_video_cache_directory() -> Path:
"""Filesystem directory for browser-safe transcoded MP4 cache.
Resolution order:
1. ``WEB_VIDEO_CACHE_DIR`` if set (absolute or relative to project root).
2. Otherwise ``<parent of UPLOAD_DIR>/web_videos`` when ``UPLOAD_DIR`` or
``PENDING_PHOTOS_DIR`` is set (same layout as ``.../pending-photos`` next to
``.../web_videos``).
3. Otherwise ``<project>/data/web_videos`` for local dev when no upload dir
is configured on the API process.
"""
explicit = (os.getenv("WEB_VIDEO_CACHE_DIR") or "").strip()
if explicit:
path = Path(explicit).expanduser()
if not path.is_absolute():
path = (_project_root() / path).resolve()
else:
path = path.resolve()
path.mkdir(parents=True, exist_ok=True)
return path
pending_raw = (os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "").strip()
if pending_raw:
pending = Path(pending_raw).expanduser()
if not pending.is_absolute():
pending = (_project_root() / pending).resolve()
else:
pending = pending.resolve()
out = (pending.parent / "web_videos").resolve()
out.mkdir(parents=True, exist_ok=True)
return out
fallback = (_project_root() / "data" / "web_videos").resolve()
fallback.mkdir(parents=True, exist_ok=True)
return fallback

View File

@ -0,0 +1,123 @@
"""Click logging utility with file rotation and management."""
from __future__ import annotations
import os
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
# Log directory - relative to project root
LOG_DIR = Path(__file__).parent.parent.parent / "logs"
LOG_FILE = LOG_DIR / "admin-clicks.log"
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
BACKUP_COUNT = 5 # Keep 5 rotated files
RETENTION_DAYS = 30 # Keep logs for 30 days
# Ensure log directory exists
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Configure logger with rotation
_logger: Optional[logging.Logger] = None
def get_click_logger() -> logging.Logger:
"""Get or create the click logger with rotation."""
global _logger
if _logger is not None:
return _logger
_logger = logging.getLogger("admin_clicks")
_logger.setLevel(logging.INFO)
# Remove existing handlers to avoid duplicates
_logger.handlers.clear()
# Create rotating file handler
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
LOG_FILE,
maxBytes=MAX_FILE_SIZE,
backupCount=BACKUP_COUNT,
encoding='utf-8'
)
# Simple format: timestamp | username | page | element_type | element_id | element_text | context
formatter = logging.Formatter(
'%(asctime)s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
_logger.addHandler(handler)
# Prevent propagation to root logger
_logger.propagate = False
return _logger
def log_click(
username: str,
page: str,
element_type: str,
element_id: Optional[str] = None,
element_text: Optional[str] = None,
context: Optional[dict] = None,
) -> None:
"""Log a click event to the log file.
Args:
username: Username of the user who clicked
page: Page/route where click occurred (e.g., '/identify')
element_type: Type of element (button, link, input, etc.)
element_id: ID of the element (optional)
element_text: Text content of the element (optional)
context: Additional context as dict (optional, will be JSON stringified)
"""
logger = get_click_logger()
# Format context as JSON string if provided
context_str = ""
if context:
import json
try:
context_str = f" | {json.dumps(context)}"
except (TypeError, ValueError):
context_str = f" | {str(context)}"
# Build log message
parts = [
username,
page,
element_type,
element_id or "",
element_text or "",
]
# Join parts with | separator, remove empty parts
message = " | ".join(part for part in parts if part) + context_str
logger.info(message)
def cleanup_old_logs() -> None:
"""Remove log files older than RETENTION_DAYS."""
if not LOG_DIR.exists():
return
from datetime import timedelta
cutoff_date = datetime.now() - timedelta(days=RETENTION_DAYS)
for log_file in LOG_DIR.glob("admin-clicks.log.*"):
try:
# Check file modification time
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
if mtime < cutoff_date:
log_file.unlink()
except (OSError, ValueError):
# Skip files we can't process
pass

View File

@ -1,404 +0,0 @@
# 6DRepNet Integration Analysis
**Date:** 2025-01-XX
**Status:** Analysis Only (No Code Changes)
**Purpose:** Evaluate feasibility of integrating 6DRepNet for direct yaw/pitch/roll estimation
---
## Executive Summary
**6DRepNet is technically feasible to implement** as an alternative or enhancement to the current RetinaFace-based landmark pose estimation. The integration would provide more accurate direct pose estimation but requires PyTorch dependency and architectural adjustments.
**Key Findings:**
- ✅ **Technically Feasible**: 6DRepNet is available as a PyPI package (`sixdrepnet`)
- ⚠️ **Dependency Conflict**: Requires PyTorch (currently using TensorFlow via DeepFace)
- ✅ **Interface Compatible**: Can work with existing OpenCV/CV2 image processing
- 📊 **Accuracy Improvement**: Direct estimation vs. geometric calculation from landmarks
- 🔄 **Architectural Impact**: Requires abstraction layer to support both methods
---
## Current Implementation Analysis
### Current Pose Detection Architecture
**Location:** `src/utils/pose_detection.py`
**Current Method:**
1. Uses RetinaFace to detect faces and extract facial landmarks
2. Calculates yaw, pitch, roll **geometrically** from landmark positions:
- **Yaw**: Calculated from nose position relative to eye midpoint
- **Pitch**: Calculated from nose position relative to expected vertical position
- **Roll**: Calculated from eye line angle
3. Uses face width (eye distance) as additional indicator for profile detection
4. Classifies pose mode from angles using thresholds
**Key Characteristics:**
- ✅ No additional ML model dependencies (uses RetinaFace landmarks)
- ✅ Lightweight (geometric calculations only)
- ⚠️ Accuracy depends on landmark quality and geometric assumptions
- ⚠️ May have limitations with extreme poses or low-quality images
**Integration Points:**
- `FaceProcessor.__init__()`: Initializes `PoseDetector` with graceful fallback
- `process_faces()`: Calls `pose_detector.detect_pose_faces(img_path)`
- `face_service.py`: Uses shared `PoseDetector` instance for batch processing
- Returns: `{'yaw_angle', 'pitch_angle', 'roll_angle', 'pose_mode', ...}`
---
## 6DRepNet Overview
### What is 6DRepNet?
6DRepNet is a PyTorch-based deep learning model designed for **direct head pose estimation** using a continuous 6D rotation matrix representation. It addresses ambiguities in rotation labels and enables robust full-range head pose predictions.
**Key Features:**
- Direct estimation of yaw, pitch, roll angles
- Full 360° range support
- Competitive accuracy (MAE ~2.66° on BIWI dataset)
- Available as easy-to-use Python package
### Technical Specifications
**Package:** `sixdrepnet` (PyPI)
**Framework:** PyTorch
**Input:** Image (OpenCV format, numpy array, or PIL Image)
**Output:** `(pitch, yaw, roll)` angles in degrees
**Model Size:** ~50-100MB (weights downloaded automatically)
**Dependencies:**
- PyTorch (CPU or CUDA)
- OpenCV (already in requirements)
- NumPy (already in requirements)
### Usage Example
```python
from sixdrepnet import SixDRepNet
import cv2
# Initialize (weights downloaded automatically)
model = SixDRepNet()
# Load image
img = cv2.imread('/path/to/image.jpg')
# Predict pose (returns pitch, yaw, roll)
pitch, yaw, roll = model.predict(img)
# Optional: visualize results
model.draw_axis(img, yaw, pitch, roll)
```
---
## Integration Feasibility Analysis
### ✅ Advantages
1. **Higher Accuracy**
- Direct ML-based estimation vs. geometric calculations
- Trained on diverse datasets, better generalization
- Handles extreme poses better than geometric methods
2. **Full Range Support**
- Supports full 360° rotation (current method may struggle with extreme angles)
- Better profile detection accuracy
3. **Simpler Integration**
- Single method call: `model.predict(img)` returns angles directly
- No need to match landmarks to faces or calculate from geometry
- Can work with face crops directly (no need for full landmarks)
4. **Consistent Interface**
- Returns same format: `(pitch, yaw, roll)` in degrees
- Can drop-in replace current `PoseDetector` class methods
### ⚠️ Challenges
1. **Dependency Conflict**
- **Current Stack:** TensorFlow (via DeepFace)
- **6DRepNet Requires:** PyTorch
- **Impact:** Both frameworks can coexist but increase memory footprint
2. **Face Detection Dependency**
- 6DRepNet requires **face crops** as input (not full images)
- Current flow: RetinaFace → landmarks → geometric calculation
- New flow: RetinaFace → face crop → 6DRepNet → angles
- Still need RetinaFace for face detection/bounding boxes
3. **Initialization Overhead**
- Model loading time on first use (~1-2 seconds)
- Model weights download (~50-100MB) on first initialization
- GPU memory usage if CUDA available (optional but faster)
4. **Processing Speed**
- **Current:** Geometric calculations (very fast, <1ms per face)
- **6DRepNet:** Neural network inference (~10-50ms per face on CPU, ~5-10ms on GPU)
- Impact on batch processing: ~10-50x slower per face
5. **Memory Footprint**
- PyTorch + model weights: ~200-500MB additional memory
- Model kept in memory for batch processing (good for performance)
---
## Architecture Compatibility
### Current Architecture
```
┌─────────────────────────────────────────┐
│ FaceProcessor │
│ ┌───────────────────────────────────┐ │
│ │ PoseDetector (RetinaFace) │ │
│ │ - detect_pose_faces(img_path) │ │
│ │ - Returns: yaw, pitch, roll │ │
│ └───────────────────────────────────┘ │
│ │
│ DeepFace (TensorFlow) │
│ - Face detection + encoding │
└─────────────────────────────────────────┘
```
### Proposed Architecture (6DRepNet)
```
┌─────────────────────────────────────────┐
│ FaceProcessor │
│ ┌───────────────────────────────────┐ │
│ │ PoseDetector (6DRepNet) │ │
│ │ - Requires: face crop (from │ │
│ │ RetinaFace/DeepFace) │ │
│ │ - model.predict(face_crop) │ │
│ │ - Returns: yaw, pitch, roll │ │
│ └───────────────────────────────────┘ │
│ │
│ DeepFace (TensorFlow) │
│ - Face detection + encoding │
│ │
│ RetinaFace (still needed) │
│ - Face detection + bounding boxes │
└─────────────────────────────────────────┘
```
### Integration Strategy Options
**Option 1: Replace Current Method**
- Remove geometric calculations
- Use 6DRepNet exclusively
- **Pros:** Simpler, one method only
- **Cons:** Loses lightweight fallback option
**Option 2: Hybrid Approach (Recommended)**
- Support both methods via configuration
- Use 6DRepNet when available, fallback to geometric
- **Pros:** Backward compatible, graceful degradation
- **Cons:** More complex code
**Option 3: Parallel Execution**
- Run both methods and compare/validate
- **Pros:** Best of both worlds, validation
- **Cons:** 2x processing time
---
## Implementation Requirements
### 1. Dependencies
**Add to `requirements.txt`:**
```txt
# 6DRepNet for direct pose estimation
sixdrepnet>=1.0.0
torch>=2.0.0 # PyTorch (CPU version)
# OR
# torch>=2.0.0+cu118 # PyTorch with CUDA support (if GPU available)
```
**Note:** PyTorch installation depends on system:
- **CPU-only:** `pip install torch` (smaller, ~150MB)
- **CUDA-enabled:** `pip install torch --index-url https://download.pytorch.org/whl/cu118` (larger, ~1GB)
### 2. Code Changes Required
**File: `src/utils/pose_detection.py`**
**New Class: `SixDRepNetPoseDetector`**
```python
class SixDRepNetPoseDetector:
"""Pose detector using 6DRepNet for direct angle estimation"""
def __init__(self):
from sixdrepnet import SixDRepNet
self.model = SixDRepNet()
def predict_pose(self, face_crop_img) -> Tuple[float, float, float]:
"""Predict yaw, pitch, roll from face crop"""
pitch, yaw, roll = self.model.predict(face_crop_img)
return yaw, pitch, roll # Match current interface (yaw, pitch, roll)
```
**Integration Points:**
1. Modify `PoseDetector.detect_pose_faces()` to optionally use 6DRepNet
2. Extract face crops from RetinaFace bounding boxes
3. Pass crops to 6DRepNet for prediction
4. Return same format as current method
**Key Challenge:** Need face crops, not just landmarks
- Current: Uses landmarks from RetinaFace
- 6DRepNet: Needs image crops (can extract from same RetinaFace detection)
### 3. Configuration Changes
**File: `src/core/config.py`**
Add configuration option:
```python
# Pose detection method: 'geometric' (current) or '6drepnet' (ML-based)
POSE_DETECTION_METHOD = 'geometric' # or '6drepnet'
```
---
## Performance Comparison
### Current Method (Geometric)
**Speed:**
- ~0.1-1ms per face (geometric calculations only)
- No model loading overhead
**Accuracy:**
- Good for frontal and moderate poses
- May struggle with extreme angles or profile views
- Depends on landmark quality
**Memory:**
- Minimal (~10-50MB for RetinaFace only)
### 6DRepNet Method
**Speed:**
- CPU: ~10-50ms per face (neural network inference)
- GPU: ~5-10ms per face (with CUDA)
- Initial model load: ~1-2 seconds (one-time)
**Accuracy:**
- Higher accuracy across all pose ranges
- Better generalization from training data
- More robust to image quality variations
**Memory:**
- Model weights: ~50-100MB
- PyTorch runtime: ~200-500MB
- Total: ~250-600MB additional
### Batch Processing Impact
**Example: Processing 1000 photos with 3 faces each = 3000 faces**
**Current Method:**
- Time: ~300-3000ms (0.3-3 seconds)
- Very fast, minimal impact
**6DRepNet (CPU):**
- Time: ~30-150 seconds (0.5-2.5 minutes)
- Significant slowdown but acceptable for batch jobs
**6DRepNet (GPU):**
- Time: ~15-30 seconds
- Much faster with GPU acceleration
---
## Recommendations
### ✅ Recommended Approach: Hybrid Implementation
**Phase 1: Add 6DRepNet as Optional Enhancement**
1. Keep current geometric method as default
2. Add 6DRepNet as optional alternative
3. Use configuration flag to enable: `POSE_DETECTION_METHOD = '6drepnet'`
4. Graceful fallback if 6DRepNet unavailable
**Phase 2: Performance Tuning**
1. Implement GPU acceleration if available
2. Batch processing optimizations
3. Cache model instance across batch operations
**Phase 3: Evaluation**
1. Compare accuracy on real dataset
2. Measure performance impact
3. Decide on default method based on results
### ⚠️ Considerations
1. **Dependency Management:**
- PyTorch + TensorFlow coexistence is possible but increases requirements
- Consider making 6DRepNet optional (extra dependency group)
2. **Face Crop Extraction:**
- Need to extract face crops from images
- Can use RetinaFace bounding boxes (already available)
- Or use DeepFace detection results
3. **Backward Compatibility:**
- Keep current method available
- Database schema unchanged (same fields: yaw_angle, pitch_angle, roll_angle)
- API interface unchanged
4. **GPU Support:**
- Optional but recommended for performance
- Can detect CUDA availability automatically
- Falls back to CPU if GPU unavailable
---
## Implementation Complexity Assessment
### Complexity: **Medium**
**Factors:**
- ✅ Interface is compatible (same output format)
- ✅ Existing architecture supports abstraction
- ⚠️ Requires face crop extraction (not just landmarks)
- ⚠️ PyTorch dependency adds complexity
- ⚠️ Performance considerations for batch processing
**Estimated Effort:**
- **Initial Implementation:** 2-4 hours
- **Testing & Validation:** 2-3 hours
- **Documentation:** 1 hour
- **Total:** ~5-8 hours
---
## Conclusion
**6DRepNet is technically feasible and recommended for integration** as an optional enhancement to the current geometric pose estimation method. The hybrid approach provides:
1. **Backward Compatibility:** Current method remains default
2. **Improved Accuracy:** Better pose estimation, especially for extreme angles
3. **Flexibility:** Users can choose method based on accuracy vs. speed tradeoff
4. **Future-Proof:** ML-based approach can be improved with model updates
**Next Steps (if proceeding):**
1. Add `sixdrepnet` and `torch` to requirements (optional dependency group)
2. Implement `SixDRepNetPoseDetector` class
3. Modify `PoseDetector` to support both methods
4. Add configuration option
5. Test on sample dataset
6. Measure performance impact
7. Update documentation
---
## References
- **6DRepNet Paper:** [6D Rotation Representation For Unconstrained Head Pose Estimation](https://www.researchgate.net/publication/358898627_6D_Rotation_Representation_For_Unconstrained_Head_Pose_Estimation)
- **PyPI Package:** [sixdrepnet](https://pypi.org/project/sixdrepnet/)
- **PyTorch Installation:** https://pytorch.org/get-started/locally/
- **Current Implementation:** `src/utils/pose_detection.py`

View File

@ -74,7 +74,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ • /api/v1/users • /api/v1/videos │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ BUSINESS LOGIC LAYER │
│ ┌──────────────────┬──────────────────┬──────────────────────────┐ │
@ -92,7 +92,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ (Multi-criteria)│ (Video Processing)│ (JWT, RBAC) │ │
│ └──────────────────┴──────────────────┴──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ DATA ACCESS LAYER │
│ ┌──────────────────────────────────────────────────────────────────┐ │
@ -103,7 +103,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ • Query optimization • Data integrity │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ ┌──────────────────────────────┬──────────────────────────────────┐ │

View File

@ -1,174 +0,0 @@
# Auto-Match Load Performance Analysis
## Summary
Auto-Match page loads significantly slower than Identify page because it lacks the performance optimizations that Identify uses. Auto-Match always fetches all data upfront with no caching, while Identify uses sessionStorage caching and lazy loading.
## Identify Page Optimizations (Current)
### 1. **SessionStorage Caching**
- **State Caching**: Caches faces, current index, similar faces, and form data in sessionStorage
- **Settings Caching**: Caches filter settings (pageSize, minQuality, sortBy, etc.)
- **Restoration**: On mount, restores cached state instead of making API calls
- **Implementation**:
- `STATE_KEY = 'identify_state'` - stores faces, currentIdx, similar, faceFormData, selectedSimilar
- `SETTINGS_KEY = 'identify_settings'` - stores filter settings
- Only loads fresh data if no cached state exists
### 2. **Lazy Loading**
- **Similar Faces**: Only loads similar faces when:
- `compareEnabled` is true
- Current face changes
- Not loaded during initial page load
- **Images**: Uses lazy loading for similar face images (`loading="lazy"`)
### 3. **Image Preloading**
- Preloads next/previous face images in background
- Uses `new Image()` to preload without blocking UI
- Delayed by 100ms to avoid blocking current image load
### 4. **Batch Operations**
- Uses `batchSimilarity` endpoint for unique faces filtering
- Single API call instead of multiple individual calls
### 5. **Progressive State Management**
- Uses refs to track restoration state
- Prevents unnecessary reloads during state restoration
- Only triggers API calls when actually needed
## Auto-Match Page (Current - No Optimizations)
### 1. **No Caching**
- **No sessionStorage**: Always makes fresh API calls on mount
- **No state restoration**: Always starts from scratch
- **No settings persistence**: Tolerance and other settings reset on page reload
### 2. **Eager Loading**
- **All Data Upfront**: Loads ALL people and ALL matches in single API call
- **No Lazy Loading**: All match data loaded even if user never views it
- **No Progressive Loading**: Everything must be loaded before UI is usable
### 3. **No Image Preloading**
- Images load on-demand as user navigates
- No preloading of next/previous person images
### 4. **Large API Response**
- Backend returns complete dataset:
- All identified people
- All matches for each person
- All face metadata (photo info, locations, quality scores, etc.)
- Response size can be very large (hundreds of KB to MB) depending on:
- Number of identified people
- Number of matches per person
- Amount of metadata per match
### 5. **Backend Processing**
The `find_auto_match_matches` function:
- Queries all identified faces (one per person, quality >= 0.3)
- For EACH person, calls `find_similar_faces` to find matches
- This means N database queries (where N = number of people)
- All processing happens synchronously before response is sent
## Performance Comparison
### Identify Page Load Flow
```
1. Check sessionStorage for cached state
2. If cached: Restore state (instant, no API call)
3. If not cached: Load faces (paginated, ~50 faces)
4. Load similar faces only when face changes (lazy)
5. Preload next/previous images (background)
```
### Auto-Match Page Load Flow
```
1. Always call API (no cache check)
2. Backend processes ALL people:
- Query all identified faces
- For each person: query similar faces
- Build complete response with all matches
3. Wait for complete response (can be large)
4. Render all data at once
```
## Key Differences
| Feature | Identify | Auto-Match |
|---------|----------|------------|
| **Caching** | ✅ sessionStorage | ❌ None |
| **State Restoration** | ✅ Yes | ❌ No |
| **Lazy Loading** | ✅ Similar faces only | ❌ All data upfront |
| **Image Preloading** | ✅ Next/prev faces | ❌ None |
| **Pagination** | ✅ Yes (page_size) | ❌ No (all at once) |
| **Progressive Loading** | ✅ Yes | ❌ No |
| **API Call Size** | Small (paginated) | Large (all data) |
| **Backend Queries** | 1-2 queries | N+1 queries (N = people) |
## Why Auto-Match is Slower
1. **No Caching**: Every page load requires full API call
2. **Large Response**: All people + all matches in single response
3. **N+1 Query Problem**: Backend makes one query per person to find matches
4. **Synchronous Processing**: All processing happens before response
5. **No Lazy Loading**: All match data loaded even if never viewed
## Potential Optimizations for Auto-Match
### 1. **Add SessionStorage Caching** (High Impact)
- Cache people list and matches in sessionStorage
- Restore on mount instead of API call
- Similar to Identify page approach
### 2. **Lazy Load Matches** (High Impact)
- Load people list first
- Load matches for current person only
- Load matches for next person in background
- Similar to how Identify loads similar faces
### 3. **Pagination** (Medium Impact)
- Paginate people list (e.g., 20 people per page)
- Load matches only for visible people
- Reduces initial response size
### 4. **Backend Optimization** (High Impact)
- Batch similarity queries instead of N+1 pattern
- Use `calculate_batch_similarities` for all people at once
- Cache results if tolerance hasn't changed
### 5. **Image Preloading** (Low Impact)
- Preload reference face images for next/previous people
- Preload match images for current person
### 6. **Progressive Rendering** (Medium Impact)
- Show people list immediately
- Load matches progressively as user navigates
- Show loading indicators for matches
## Code Locations
### Identify Page
- **Frontend**: `frontend/src/pages/Identify.tsx`
- Lines 42-45: SessionStorage keys
- Lines 272-347: State restoration logic
- Lines 349-399: State saving logic
- Lines 496-527: Image preloading
- Lines 258-270: Lazy loading of similar faces
### Auto-Match Page
- **Frontend**: `frontend/src/pages/AutoMatch.tsx`
- Lines 35-71: `loadAutoMatch` function (always calls API)
- Lines 74-77: Auto-load on mount (no cache check)
### Backend
- **API Endpoint**: `src/web/api/faces.py` (lines 539-702)
- **Service Function**: `src/web/services/face_service.py` (lines 1736-1846)
- `find_auto_match_matches`: Processes all people synchronously
## Recommendations
1. **Immediate**: Add sessionStorage caching (similar to Identify)
2. **High Priority**: Implement lazy loading of matches
3. **Medium Priority**: Optimize backend to use batch queries
4. **Low Priority**: Add image preloading
The biggest win would be adding sessionStorage caching, which would make subsequent page loads instant (like Identify).

View File

@ -1,219 +0,0 @@
# Client Deployment Questions
**PunimTag Web Application - Information Needed for Deployment**
We have the source code ready. To deploy on your server, we need the following information:
---
## 1. Server Access
**How can we access your server?**
- [ ] SSH access
- Server IP/hostname: `_________________`
- SSH port: `_________________` (default: 22)
- Username: `_________________`
- Authentication method:
- [ ] SSH key (provide public key or key file)
- [ ] Username/password: `_________________`
- [ ] Other access method: `_________________`
**Do we have permission to install software?**
- [ ] Yes, we can install packages
- [ ] No, limited permissions (what can we do?): `_________________`
---
## 2. Databases
**We need TWO PostgreSQL databases:**
### Main Database (for photos, faces, people, tags)
- **Database server location:**
- [ ] Same server as application
- [ ] Different server: `_________________`
- **Connection details:**
- Host/IP: `_________________`
- Port: `_________________` (default: 5432)
- Database name: `_________________` (or we can create: `punimtag`)
- Username: `_________________`
- Password: `_________________`
- **Can we create the database?**
- [ ] Yes
- [ ] No (provide existing database details above)
### Auth Database (for frontend website user accounts)
- **Database server location:**
- [ ] Same server as main database
- [ ] Same server as application (different database)
- [ ] Different server: `_________________`
- **Connection details:**
- Host/IP: `_________________`
- Port: `_________________` (default: 5432)
- Database name: `_________________` (or we can create: `punimtag_auth`)
- Username: `_________________`
- Password: `_________________`
- **Can we create the database?**
- [ ] Yes
- [ ] No (provide existing database details above)
**Database access:**
- Can the application server connect to the databases?
- [ ] Yes, direct connection
- [ ] VPN required: `_________________`
- [ ] IP whitelist required: `_________________`
---
## 3. Redis (for background jobs)
**Redis server:**
- [ ] Same server as application
- [ ] Different server: `_________________`
- [ ] Not installed (we can install)
**If separate server:**
- Host/IP: `_________________`
- Port: `_________________` (default: 6379)
- Password (if required): `_________________`
---
## 4. Network & Ports
**What ports can we use?**
- Backend API (port 8000):
- [ ] Can use port 8000
- [ ] Need different port: `_________________`
- Frontend (port 3000 for dev, or web server for production):
- [ ] Can use port 3000
- [ ] Need different port: `_________________`
- [ ] Will use web server (Nginx/Apache) - port 80/443
**Who needs to access the application?**
- [ ] Internal network only
- [ ] External users (internet)
- [ ] VPN users only
- [ ] Specific IP ranges: `_________________`
**Domain/URL:**
- Do you have a domain name? `_________________`
- What URL should users access? `_________________` (e.g., `https://punimtag.yourdomain.com`)
**Firewall:**
- [ ] We can configure firewall rules
- [ ] IT team manages firewall (contact: `_________________`)
---
## 5. Frontend Website
**How should the frontend be served?**
- [ ] Development mode (Vite dev server)
- [ ] Production build with web server (Nginx/Apache)
- [ ] Other: `_________________`
**Backend API URL for frontend:**
- What URL should the frontend use to connect to the backend API?
- `_________________` (e.g., `http://server-ip:8000` or `https://api.yourdomain.com`)
- **Important:** This URL must be accessible from users' browsers (not just localhost)
**Web server (if using production build):**
- [ ] Nginx installed
- [ ] Apache installed
- [ ] Not installed (we can install/configure)
- [ ] Other: `_________________`
---
## 6. Storage
**Where should uploaded photos be stored?**
- Storage path: `_________________` (e.g., `/var/punimtag/photos` or `/data/uploads`)
- [ ] We can create and configure the directory
- [ ] Directory already exists: `_________________`
**Storage type:**
- [ ] Local disk
- [ ] Network storage (NAS): `_________________`
- [ ] Other: `_________________`
---
## 7. Software Installation
**What's already installed on the server?**
- Python 3.12+: [ ] Yes [ ] No
- Node.js 18+: [ ] Yes [ ] No
- PostgreSQL: [ ] Yes [ ] No
- Redis: [ ] Yes [ ] No
- Git: [ ] Yes [ ] No
**Can we install missing software?**
- [ ] Yes
- [ ] No (what's available?): `_________________`
**Does the server have internet access?**
- [ ] Yes (can download packages)
- [ ] No (internal package repository?): `_________________`
---
## 8. SSL/HTTPS
**Do you need HTTPS?**
- [ ] Yes (SSL certificate required)
- [ ] We can generate self-signed certificate
- [ ] You will provide certificate
- [ ] Let's Encrypt (domain required)
- [ ] No (HTTP is fine for testing)
---
## 9. Code Deployment
**How should we deploy the code?**
- [ ] Git repository access
- Repository URL: `_________________`
- Access credentials: `_________________`
- [ ] File transfer (SFTP/SCP)
- [ ] We will provide deployment package
- [ ] Other: `_________________`
---
## 10. Contact Information
**Who should we contact for:**
- IT/Network issues: `_________________` (email: `_________________`, phone: `_________________`)
- Database issues: `_________________` (email: `_________________`, phone: `_________________`)
- General questions: `_________________` (email: `_________________`, phone: `_________________`)
---
## Quick Summary
**What we need:**
1. ✅ Server access (SSH)
2. ✅ Two PostgreSQL databases (main + auth)
3. ✅ Redis server
4. ✅ Network ports (8000 for API, 3000 or web server for frontend)
5. ✅ Storage location for photos
6. ✅ Frontend API URL configuration
7. ✅ Contact information
**What we'll do:**
- Install required software (if needed)
- Configure databases
- Deploy and configure the application
- Set up frontend website
- Test everything works
---
**Please fill out this form and return it to us so we can begin deployment.**

View File

@ -1,505 +0,0 @@
# Client Network Testing Information Request
**PunimTag Web Application - Network Testing Setup**
This document outlines the information required from your organization to begin testing the PunimTag web application on your network infrastructure.
---
## 1. Server Access & Infrastructure
### 1.1 Server Details
- **Server Hostname/IP Address**: `_________________`
- **Operating System**: `_________________` (e.g., Ubuntu 22.04, RHEL 9, Windows Server 2022)
- **SSH Access Method**:
- [ ] SSH Key-based authentication (provide public key)
- [ ] Username/Password authentication
- **SSH Port**: `_________________` (default: 22)
- **SSH Username**: `_________________`
- **SSH Credentials**: `_________________` (or key file location)
- **Sudo/Root Access**:
- [ ] Yes (required for service installation)
- [ ] No (limited permissions - specify what's available)
### 1.2 Server Specifications
- **CPU**: `_________________` (cores/threads)
- **RAM**: `_________________` GB
- **Disk Space Available**: `_________________` GB
- **Network Bandwidth**: `_________________` Mbps
- **Is this a virtual machine or physical server?**: `_________________`
---
## 2. Network Configuration
### 2.1 Network Topology
- **Network Type**:
- [ ] Internal/Private network only
- [ ] Internet-facing with public IP
- [ ] VPN-accessible only
- [ ] Hybrid (internal + external access)
### 2.2 IP Addresses & Ports
- **Server IP Address**: `_________________`
- **Internal Network Range**: `_________________` (e.g., 192.168.1.0/24)
- **Public IP Address** (if applicable): `_________________`
- **Domain Name** (if applicable): `_________________`
- **Subdomain** (if applicable): `_________________` (e.g., punimtag.yourdomain.com)
### 2.3 Firewall Rules
Please confirm that the following ports can be opened for the application:
**Required Ports:**
- **Port 8000** (Backend API) - TCP
- [ ] Can be opened
- [ ] Cannot be opened (alternative port needed: `_________________`)
- **Port 3000** (Frontend) - TCP
- [ ] Can be opened
- [ ] Cannot be opened (alternative port needed: `_________________`)
- **Port 5432** (PostgreSQL) - TCP
- [ ] Can be opened (if database is on separate server)
- [ ] Internal only (localhost)
- [ ] Cannot be opened (alternative port needed: `_________________`)
- **Port 6379** (Redis) - TCP
- [ ] Can be opened (if Redis is on separate server)
- [ ] Internal only (localhost)
- [ ] Cannot be opened (alternative port needed: `_________________`)
**Additional Ports (if using reverse proxy):**
- **Port 80** (HTTP) - TCP
- **Port 443** (HTTPS) - TCP
### 2.4 Network Access Requirements
- **Who needs access to the application?**
- [ ] Internal users only (same network)
- [ ] External users (internet access)
- [ ] VPN users only
- [ ] Specific IP ranges: `_________________`
- **Do users need to access from outside the network?**
- [ ] Yes (requires public IP or VPN)
- [ ] No (internal only)
### 2.5 Proxy/VPN Configuration
- **Is there a proxy server?**
- [ ] Yes
- Proxy address: `_________________`
- Proxy port: `_________________`
- Authentication required: [ ] Yes [ ] No
- Credentials: `_________________`
- [ ] No
- **VPN Requirements:**
- [ ] VPN access required for testing team
- [ ] VPN type: `_________________` (OpenVPN, Cisco AnyConnect, etc.)
- [ ] VPN credentials/configuration: `_________________`
---
## 3. Database Configuration
### 3.1 PostgreSQL Database
- **Database Server Location**:
- [ ] Same server as application
- [ ] Separate server (provide details below)
**If separate database server:**
- **Database Server IP/Hostname**: `_________________`
- **Database Port**: `_________________` (default: 5432)
- **Database Name**: `_________________` (or we can create: `punimtag`)
- **Database Username**: `_________________`
- **Database Password**: `_________________`
- **Database Version**: `_________________` (PostgreSQL 12+ required)
**If database needs to be created:**
- **Can we create the database?** [ ] Yes [ ] No
- **Database administrator credentials**: `_________________`
- **Preferred database name**: `_________________`
### 3.2 Database Access
- **Network access to database**:
- [ ] Direct connection from application server
- [ ] VPN required
- [ ] Specific IP whitelist required: `_________________`
### 3.3 Database Backup Requirements
- **Backup policy**: `_________________`
- **Backup location**: `_________________`
- **Backup schedule**: `_________________`
### 3.4 Auth Database (Frontend Website Authentication)
The application uses a **separate authentication database** for the frontend website user accounts.
- **Auth Database Server Location**:
- [ ] Same server as main database
- [ ] Same server as application (different database)
- [ ] Separate server (provide details below)
**If separate auth database server:**
- **Auth Database Server IP/Hostname**: `_________________`
- **Auth Database Port**: `_________________` (default: 5432)
- **Auth Database Name**: `_________________` (or we can create: `punimtag_auth`)
- **Auth Database Username**: `_________________`
- **Auth Database Password**: `_________________`
- **Auth Database Version**: `_________________` (PostgreSQL 12+ required)
**If auth database needs to be created:**
- **Can we create the auth database?** [ ] Yes [ ] No
- **Database administrator credentials**: `_________________`
- **Preferred database name**: `_________________` (default: `punimtag_auth`)
**Auth Database Access:**
- **Network access to auth database**:
- [ ] Direct connection from application server
- [ ] VPN required
- [ ] Specific IP whitelist required: `_________________`
**Note:** The auth database stores user accounts for the frontend website (separate from backend admin users). It requires its own connection string configured as `DATABASE_URL_AUTH`.
---
## 4. Redis Configuration
### 4.1 Redis Server
- **Redis Server Location**:
- [ ] Same server as application
- [ ] Separate server (provide details below)
- [ ] Not installed (we can install)
**If separate Redis server:**
- **Redis Server IP/Hostname**: `_________________`
- **Redis Port**: `_________________` (default: 6379)
- **Redis Password** (if password-protected): `_________________`
**If Redis needs to be installed:**
- **Can we install Redis?** [ ] Yes [ ] No
- **Preferred installation method**:
- [ ] Package manager (apt/yum)
- [ ] Docker container
- [ ] Manual compilation
---
## 5. Storage & File System
### 5.1 Photo Storage
- **Storage Location**: `_________________` (e.g., /var/punimtag/photos, /data/uploads)
- **Storage Capacity**: `_________________` GB
- **Storage Type**:
- [ ] Local disk
- [ ] Network attached storage (NAS)
- [ ] Cloud storage (specify: `_________________`)
- **Storage Path Permissions**:
- [ ] We can create and configure
- [ ] Pre-configured (provide path: `_________________`)
### 5.2 File System Access
- **Mount points** (if using NAS): `_________________`
- **NFS/SMB configuration** (if applicable): `_________________`
- **Disk quotas**: `_________________` (if applicable)
---
## 6. Software Prerequisites
### 6.1 Installed Software
Please confirm if the following are already installed:
**Backend Requirements:**
- **Python 3.12+**:
- [ ] Installed (version: `_________________`)
- [ ] Not installed (we can install)
- **PostgreSQL**:
- [ ] Installed (version: `_________________`)
- [ ] Not installed (we can install)
- **Redis**:
- [ ] Installed (version: `_________________`)
- [ ] Not installed (we can install)
**Frontend Requirements:**
- **Node.js 18+**:
- [ ] Installed (version: `_________________`)
- [ ] Not installed (we can install)
- **npm**:
- [ ] Installed (version: `_________________`)
- [ ] Not installed (we can install)
- **Web Server** (for serving built frontend):
- [ ] Nginx (version: `_________________`)
- [ ] Apache (version: `_________________`)
- [ ] Other: `_________________`
- [ ] Not installed (we can install/configure)
**Development Tools:**
- **Git**:
- [ ] Installed
- [ ] Not installed (we can install)
### 6.2 Installation Permissions
- **Can we install software packages?** [ ] Yes [ ] No
- **Package manager available**:
- [ ] apt (Debian/Ubuntu)
- [ ] yum/dnf (RHEL/CentOS)
- [ ] Other: `_________________`
### 6.3 Internet Access
- **Does the server have internet access?** [ ] Yes [ ] No
- **If yes, can it download packages?** [ ] Yes [ ] No
- **If no, do you have an internal package repository?**
- [ ] Yes (provide details: `_________________`)
- [ ] No
---
## 7. Security & Authentication
### 7.1 SSL/TLS Certificates
- **SSL Certificate Required?**
- [ ] Yes (HTTPS required)
- [ ] No (HTTP acceptable for testing)
- **Certificate Type**:
- [ ] Self-signed (we can generate)
- [ ] Organization CA certificate
- [ ] Let's Encrypt
- [ ] Commercial certificate
- **Certificate Location** (if provided): `_________________`
### 7.2 Authentication & Access Control
- **Default Admin Credentials**:
- Username: `_________________` (or use default: `admin`)
- Password: `_________________` (or use default: `admin`)
- **User Accounts**:
- [ ] Single admin account only
- [ ] Multiple test user accounts needed
- Number of test users: `_________________`
- User details: `_________________`
### 7.3 Security Policies
- **Firewall rules**:
- [ ] Managed by IT team (provide contact: `_________________`)
- [ ] We can configure
- **Security scanning requirements**: `_________________`
- **Compliance requirements**: `_________________` (e.g., HIPAA, GDPR, SOC 2)
---
## 8. Monitoring & Logging
### 8.1 Logging
- **Log file location**: `_________________` (default: application directory)
- **Log retention policy**: `_________________`
- **Centralized logging system**:
- [ ] Yes (provide details: `_________________`)
- [ ] No
### 8.2 Monitoring
- **Monitoring tools in use**: `_________________`
- **Do you need application metrics?** [ ] Yes [ ] No
- **Health check endpoints**:
- [ ] Available at `/api/v1/health`
- [ ] Custom endpoint needed: `_________________`
---
## 9. Testing Requirements
### 9.1 Test Data
- **Sample photos for testing**:
- [ ] We will provide test photos
- [ ] You will provide test photos
- [ ] Location of test photos: `_________________`
- **Expected photo volume for testing**: `_________________` photos
- **Photo size range**: `_________________` MB per photo
### 9.2 Test Users
- **Number of concurrent test users**: `_________________`
- **Test user accounts needed**:
- [ ] Yes (provide usernames: `_________________`)
- [ ] No (use default admin account)
### 9.3 Testing Schedule
- **Preferred testing window**:
- Start date: `_________________`
- End date: `_________________`
- Preferred time: `_________________` (timezone: `_________________`)
- **Maintenance windows** (if any): `_________________`
---
## 10. Frontend Website Configuration
### 10.1 Frontend Deployment Method
- **How will the frontend be served?**
- [ ] Development mode (Vite dev server on port 3000)
- [ ] Production build served by web server (Nginx/Apache)
- [ ] Static file hosting (CDN, S3, etc.)
- [ ] Docker container
- [ ] Other: `_________________`
### 10.2 Frontend Environment Variables
The frontend React application requires the following configuration:
- **Backend API URL** (`VITE_API_URL`):
- Development: `http://localhost:8000` or `http://127.0.0.1:8000`
- Production: `_________________` (e.g., `https://api.yourdomain.com` or `http://server-ip:8000`)
- **Note:** This must be accessible from users' browsers (not just localhost)
### 10.3 Frontend Build Requirements
- **Build location**: `_________________` (where built files will be placed)
- **Build process**:
- [ ] We will build on the server
- [ ] We will provide pre-built files
- [ ] Build will be done on a separate build server
- **Static file serving**:
- [ ] Nginx configured
- [ ] Apache configured
- [ ] Needs to be configured: `_________________`
### 10.4 Frontend Access
- **Frontend URL/Domain**: `_________________` (e.g., `https://punimtag.yourdomain.com` or `http://server-ip:3000`)
- **HTTPS Required?**
- [ ] Yes (SSL certificate needed)
- [ ] No (HTTP acceptable for testing)
- **CORS Configuration**:
- [ ] Needs to be configured
- [ ] Already configured
- **Allowed origins**: `_________________`
---
## 11. Deployment Method
### 11.1 Preferred Deployment
- **Deployment method**:
- [ ] Direct installation on server
- [ ] Docker containers
- [ ] Docker Compose
- [ ] Kubernetes
- [ ] Other: `_________________`
### 11.2 Code Deployment
- **How will code be deployed?**
- [ ] Git repository access (provide URL: `_________________`)
- [ ] File transfer (SFTP/SCP)
- [ ] We will provide deployment package
- **Repository access credentials**: `_________________`
---
## 12. Environment Variables Summary
For your reference, here are all the environment variables that need to be configured:
**Backend Environment Variables:**
- `DATABASE_URL` - Main database connection (PostgreSQL or SQLite)
- Example: `postgresql+psycopg2://user:password@host:5432/punimtag`
- `DATABASE_URL_AUTH` - Auth database connection for frontend website users (PostgreSQL)
- Example: `postgresql+psycopg2://user:password@host:5432/punimtag_auth`
- `SECRET_KEY` - JWT secret key (change in production!)
- `ADMIN_USERNAME` - Default admin username (optional, for backward compatibility)
- `ADMIN_PASSWORD` - Default admin password (optional, for backward compatibility)
- `PHOTO_STORAGE_DIR` - Directory for storing uploaded photos (default: `data/uploads`)
**Frontend Environment Variables:**
- `VITE_API_URL` - Backend API URL (must be accessible from browsers)
- Example: `http://server-ip:8000` or `https://api.yourdomain.com`
**Note:** All environment variables should be set securely and not exposed in version control.
---
## 13. Contact Information
### 13.1 Primary Contacts
- **IT/Network Administrator**:
- Name: `_________________`
- Email: `_________________`
- Phone: `_________________`
- **Database Administrator**:
- Name: `_________________`
- Email: `_________________`
- Phone: `_________________`
- **Project Manager/Point of Contact**:
- Name: `_________________`
- Email: `_________________`
- Phone: `_________________`
### 13.2 Emergency Contacts
- **After-hours support contact**: `_________________`
- **Escalation procedure**: `_________________`
---
## 14. Additional Requirements
### 14.1 Custom Configuration
- **Custom domain/subdomain**: `_________________`
- **Custom branding**: `_________________`
- **Integration requirements**: `_________________`
- **Special network requirements**: `_________________`
### 14.2 Documentation
- **Network diagrams**: `_________________` (if available)
- **Existing infrastructure documentation**: `_________________`
- **Change management process**: `_________________`
### 14.3 Other Notes
- **Any other relevant information**:
```
_________________________________________________
_________________________________________________
_________________________________________________
```
---
## Application Requirements Summary
For your reference, here are the key technical requirements:
**Application Components:**
- Backend API (FastAPI) - Port 8000
- Frontend Website (React) - Port 3000 (dev) or served via web server (production)
- Main PostgreSQL Database - Port 5432 (stores photos, faces, people, tags)
- Auth PostgreSQL Database - Port 5432 (stores frontend website user accounts)
- Redis (for background jobs) - Port 6379
**System Requirements:**
- Python 3.12 or higher (backend)
- Node.js 18 or higher (frontend build)
- PostgreSQL 12 or higher (both databases)
- Redis 5.0 or higher
- Web server (Nginx/Apache) for production frontend serving
- Minimum 4GB RAM (8GB+ recommended)
- Sufficient disk space for photo storage
**Network Requirements:**
- TCP ports: 3000 (dev frontend), 8000 (backend API)
- TCP ports: 5432 (databases), 6379 (Redis) - if services are remote
- HTTP/HTTPS access for users to frontend website
- Network connectivity between:
- Application server ↔ Main database
- Application server ↔ Auth database
- Application server ↔ Redis
- Users' browsers ↔ Frontend website
- Users' browsers ↔ Backend API (via VITE_API_URL)
---
## Next Steps
Once this information is provided, we will:
1. Review the network configuration
2. Prepare deployment scripts and configuration files
3. Schedule a deployment window
4. Perform initial setup and testing
5. Provide access credentials and documentation
**Please return this completed form to:** `_________________`
**Deadline for information:** `_________________`
---
*Document Version: 1.0*
*Last Updated: [Current Date]*

View File

@ -1,89 +0,0 @@
# Confidence Calibration Implementation
## Problem Solved
The identify UI was showing confidence percentages that were **not** actual match probabilities. The old calculation used a simple linear transformation:
```python
confidence_pct = (1 - distance) * 100
```
This gave misleading results:
- Distance 0.6 (at threshold) showed 40% confidence
- Distance 1.0 showed 0% confidence
- Distance 2.0 showed -100% confidence (impossible!)
## Solution: Empirical Confidence Calibration
Implemented a proper confidence calibration system that converts DeepFace distance values to actual match probabilities based on empirical analysis of the ArcFace model.
### Key Improvements
1. **Realistic Probabilities**:
- Distance 0.6 (threshold) now shows ~55% confidence (realistic)
- Distance 1.0 shows ~17% confidence (not 0%)
- No negative percentages
2. **Non-linear Mapping**: Accounts for the actual distribution of distances in face recognition
3. **Configurable Methods**: Support for different calibration approaches:
- `empirical`: Based on DeepFace ArcFace characteristics (default)
- `sigmoid`: Sigmoid-based calibration
- `linear`: Original linear transformation (fallback)
### Calibration Curve
The empirical calibration uses different approaches for different distance ranges:
- **Very Close (≤ 0.5×tolerance)**: 95-100% confidence (exponential decay)
- **Near Threshold (≤ tolerance)**: 55-95% confidence (linear interpolation)
- **Above Threshold (≤ 1.5×tolerance)**: 20-55% confidence (rapid decay)
- **Very Far (> 1.5×tolerance)**: 1-20% confidence (exponential decay)
### Configuration
Added new settings in `src/core/config.py`:
```python
USE_CALIBRATED_CONFIDENCE = True # Enable/disable calibration
CONFIDENCE_CALIBRATION_METHOD = "empirical" # Calibration method
```
### Files Modified
1. **`src/core/face_processing.py`**: Added calibration methods
2. **`src/gui/identify_panel.py`**: Updated to use calibrated confidence
3. **`src/gui/auto_match_panel.py`**: Updated to use calibrated confidence
4. **`src/core/config.py`**: Added calibration settings
5. **`src/photo_tagger.py`**: Updated to use calibrated confidence
### Test Results
The test script shows significant improvements:
| Distance | Old Linear | New Calibrated | Improvement |
|----------|-------------|----------------|-------------|
| 0.6 | 40.0% | 55.0% | +15.0% |
| 1.0 | 0.0% | 17.2% | +17.2% |
| 1.5 | -50.0% | 8.1% | +58.1% |
### Usage
The calibrated confidence is now automatically used throughout the application. Users will see more realistic match probabilities that better reflect the actual likelihood of a face match.
### Future Enhancements
1. **Dynamic Calibration**: Learn from user feedback to improve calibration
2. **Model-Specific Calibration**: Different calibration for different DeepFace models
3. **Quality-Aware Calibration**: Adjust confidence based on face quality scores
4. **User Preferences**: Allow users to adjust calibration sensitivity
## Technical Details
The calibration system uses empirical parameters derived from analysis of DeepFace ArcFace model behavior. The key insight is that face recognition distances don't follow a linear relationship with match probability - they follow a more complex distribution that varies by distance range.
This implementation provides a foundation for more sophisticated calibration methods while maintaining backward compatibility through configuration options.

View File

@ -1,406 +0,0 @@
# 🎉 DeepFace Migration COMPLETE! 🎉
**Date:** October 16, 2025
**Status:** ✅ ALL PHASES COMPLETE
**Total Tests:** 14/14 PASSING
---
## Executive Summary
The complete migration from `face_recognition` to `DeepFace` has been successfully completed across all three phases! PunimTag now uses state-of-the-art face detection (RetinaFace) and recognition (ArcFace) with 512-dimensional embeddings for superior accuracy.
---
## Phase Completion Summary
### ✅ Phase 1: Database Schema Updates
**Status:** COMPLETE
**Tests:** 4/4 passing
**Completed:** Database schema updated with DeepFace-specific columns
**Key Changes:**
- Added `detector_backend`, `model_name`, `face_confidence` to `faces` table
- Added `detector_backend`, `model_name` to `person_encodings` table
- Updated `add_face()` and `add_person_encoding()` methods
- Created migration script
**Documentation:** `PHASE1_COMPLETE.md`
---
### ✅ Phase 2: Configuration Updates
**Status:** COMPLETE
**Tests:** 5/5 passing
**Completed:** TensorFlow suppression and GUI controls added
**Key Changes:**
- Added TensorFlow warning suppression to all entry points
- Updated `FaceProcessor.__init__()` to accept detector/model parameters
- Added detector and model selection dropdowns to GUI
- Updated process callback to pass settings
**Documentation:** `PHASE2_COMPLETE.md`
---
### ✅ Phase 3: Core Face Processing Migration
**Status:** COMPLETE
**Tests:** 5/5 passing
**Completed:** Complete replacement of face_recognition with DeepFace
**Key Changes:**
- Replaced face detection with `DeepFace.represent()`
- Implemented cosine similarity for matching
- Updated location format handling (dict vs tuple)
- Adjusted adaptive tolerance for DeepFace
- 512-dimensional encodings (vs 128)
**Documentation:** `PHASE3_COMPLETE.md`
---
## Overall Test Results
```
Phase 1 Tests: 4/4 ✅
✅ PASS: Schema Columns
✅ PASS: add_face() Method
✅ PASS: add_person_encoding() Method
✅ PASS: Config Constants
Phase 2 Tests: 5/5 ✅
✅ PASS: TensorFlow Suppression
✅ PASS: FaceProcessor Initialization
✅ PASS: Config Imports
✅ PASS: Entry Point Imports
✅ PASS: GUI Config Constants
Phase 3 Tests: 5/5 ✅
✅ PASS: DeepFace Import
✅ PASS: DeepFace Detection
✅ PASS: Cosine Similarity
✅ PASS: Location Format Handling
✅ PASS: End-to-End Processing
TOTAL: 14/14 tests passing ✅
```
---
## Technical Comparison
### Before Migration (face_recognition)
| Feature | Value |
|---------|-------|
| Detection | HOG/CNN (dlib) |
| Model | dlib ResNet |
| Encoding Size | 128 dimensions |
| Storage | 1,024 bytes/face |
| Similarity Metric | Euclidean distance |
| Location Format | (top, right, bottom, left) |
| Tolerance | 0.6 |
### After Migration (DeepFace)
| Feature | Value |
|---------|-------|
| Detection | RetinaFace/MTCNN/OpenCV/SSD ⭐ |
| Model | ArcFace ⭐ |
| Encoding Size | 512 dimensions ⭐ |
| Storage | 4,096 bytes/face |
| Similarity Metric | Cosine similarity ⭐ |
| Location Format | {x, y, w, h} |
| Tolerance | 0.4 |
---
## Key Improvements
### 🎯 Accuracy
- ✅ State-of-the-art ArcFace model
- ✅ Better detection in difficult conditions
- ✅ More robust to pose variations
- ✅ Superior cross-age recognition
- ✅ Lower false positive rate
### 🔧 Flexibility
- ✅ 4 detector backends to choose from
- ✅ 4 recognition models to choose from
- ✅ GUI controls for easy switching
- ✅ Configurable settings per run
### 📊 Information
- ✅ Face confidence scores from detector
- ✅ Detailed facial landmark detection
- ✅ Quality scoring preserved
- ✅ Better match confidence metrics
---
## Files Created/Modified
### Created Files (9):
1. `PHASE1_COMPLETE.md` - Phase 1 documentation
2. `PHASE2_COMPLETE.md` - Phase 2 documentation
3. `PHASE3_COMPLETE.md` - Phase 3 documentation
4. `DEEPFACE_MIGRATION_COMPLETE.md` - This file
5. `scripts/migrate_to_deepface.py` - Migration script
6. `tests/test_phase1_schema.py` - Phase 1 tests
7. `tests/test_phase2_config.py` - Phase 2 tests
8. `tests/test_phase3_deepface.py` - Phase 3 tests
9. `.notes/phase1_quickstart.md` & `phase2_quickstart.md` - Quick references
### Modified Files (6):
1. `requirements.txt` - Updated dependencies
2. `src/core/config.py` - DeepFace configuration
3. `src/core/database.py` - Schema updates
4. `src/core/face_processing.py` - Complete DeepFace integration
5. `src/gui/dashboard_gui.py` - GUI controls
6. `run_dashboard.py` - Callback updates
---
## Migration Path
### For New Installations:
```bash
# Install dependencies
pip install -r requirements.txt
# Run the application
python3 run_dashboard.py
# Add photos and process with DeepFace
# Select detector and model in GUI
```
### For Existing Installations:
```bash
# IMPORTANT: Backup your database first!
cp data/photos.db data/photos.db.backup
# Install new dependencies
pip install -r requirements.txt
# Run migration (DELETES ALL DATA!)
python3 scripts/migrate_to_deepface.py
# Type: DELETE ALL DATA
# Re-add photos and process
python3 run_dashboard.py
```
---
## Running All Tests
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
# Phase 1 tests
python3 tests/test_phase1_schema.py
# Phase 2 tests
python3 tests/test_phase2_config.py
# Phase 3 tests
python3 tests/test_phase3_deepface.py
```
Expected: All 14 tests pass ✅
---
## Configuration Options
### Available Detectors:
1. **retinaface** (default) - Best accuracy
2. **mtcnn** - Good balance
3. **opencv** - Fastest
4. **ssd** - Good balance
### Available Models:
1. **ArcFace** (default) - 512-dim, best accuracy
2. **Facenet** - 128-dim, fast
3. **Facenet512** - 512-dim, very good
4. **VGG-Face** - 2622-dim, good
### How to Change:
1. Open GUI: `python3 run_dashboard.py`
2. Click "🔍 Process"
3. Select detector and model from dropdowns
4. Click "Start Processing"
---
## Performance Notes
### Processing Speed:
- ~2-3x slower than face_recognition
- Worth it for significantly better accuracy!
- Use GPU for faster processing (future enhancement)
### First Run:
- Downloads models (~100MB+)
- Stored in `~/.deepface/weights/`
- Subsequent runs are faster
### Memory Usage:
- Higher due to larger encodings (4KB vs 1KB)
- Deep learning models in memory
- Acceptable for desktop application
---
## Known Limitations
1. **Cannot migrate old encodings:** 128-dim → 512-dim incompatible
2. **Must re-process:** All faces need to be detected again
3. **Slower processing:** ~2-3x slower (but more accurate)
4. **GPU not used:** CPU-only for now (future enhancement)
5. **Model downloads:** First run requires internet
---
## Troubleshooting
### "DeepFace not available" warning?
```bash
pip install deepface tensorflow opencv-python retina-face
```
### TensorFlow warnings?
Already suppressed in code. If you see warnings, they're from first import only.
### "No module named 'deepface'"?
Make sure you're in the virtual environment:
```bash
source venv/bin/activate
pip install -r requirements.txt
```
### Processing very slow?
- Use 'opencv' detector for speed (lower accuracy)
- Use 'Facenet' model for speed (128-dim)
- Future: Enable GPU acceleration
---
## Success Criteria Met
All original migration goals achieved:
- [x] Replace face_recognition with DeepFace
- [x] Use ArcFace model for best accuracy
- [x] Support multiple detector backends
- [x] 512-dimensional encodings
- [x] Cosine similarity for matching
- [x] GUI controls for settings
- [x] Database schema updated
- [x] All tests passing
- [x] Documentation complete
- [x] No backward compatibility issues
- [x] Production ready
---
## Statistics
- **Development Time:** 1 day
- **Lines of Code Changed:** ~600 lines
- **Files Created:** 9 files
- **Files Modified:** 6 files
- **Tests Written:** 14 tests
- **Test Pass Rate:** 100%
- **Linter Errors:** 0
- **Breaking Changes:** Database migration required
---
## What's Next?
The migration is **COMPLETE!** Optional future enhancements:
### Optional Phase 4: GUI Enhancements
- Visual indicators for detector/model in use
- Face confidence display in UI
- Batch processing UI improvements
### Optional Phase 5: Performance
- GPU acceleration
- Multi-threading
- Model caching optimizations
### Optional Phase 6: Advanced Features
- Age estimation
- Emotion detection
- Face clustering
- Gender detection
---
## Acknowledgments
### Libraries Used:
- **DeepFace:** Modern face recognition library
- **TensorFlow:** Deep learning backend
- **OpenCV:** Image processing
- **RetinaFace:** State-of-the-art face detection
- **NumPy:** Numerical computing
- **Pillow:** Image manipulation
### References:
- DeepFace: https://github.com/serengil/deepface
- ArcFace: https://arxiv.org/abs/1801.07698
- RetinaFace: https://arxiv.org/abs/1905.00641
---
## Final Validation
Run this to validate everything works:
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
# Quick validation
python3 -c "
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
from deepface import DeepFace
print('✅ All imports successful')
db = DatabaseManager(':memory:')
fp = FaceProcessor(db, detector_backend='retinaface', model_name='ArcFace')
print(f'✅ FaceProcessor initialized: {fp.detector_backend}/{fp.model_name}')
print('🎉 DeepFace migration COMPLETE!')
"
```
Expected output:
```
✅ All imports successful
✅ FaceProcessor initialized: retinaface/ArcFace
🎉 DeepFace migration COMPLETE!
```
---
**🎉 CONGRATULATIONS! 🎉**
**The PunimTag system has been successfully migrated to DeepFace with state-of-the-art face detection and recognition capabilities!**
**All phases complete. All tests passing. Production ready!**
---
*For detailed information about each phase, see:*
- `PHASE1_COMPLETE.md` - Database schema updates
- `PHASE2_COMPLETE.md` - Configuration and GUI updates
- `PHASE3_COMPLETE.md` - Core processing migration
- `.notes/deepface_migration_plan.md` - Original migration plan

View File

@ -1,473 +0,0 @@
# DeepFace Migration Complete - Final Summary
**Date:** October 16, 2025
**Status:** ✅ 100% COMPLETE
**All Tests:** PASSING (20/20)
---
## 🎉 Migration Complete!
The complete migration from face_recognition to DeepFace is **FINISHED**! All 6 technical phases have been successfully implemented, tested, and documented.
---
## Migration Phases Status
| Phase | Status | Tests | Description |
|-------|--------|-------|-------------|
| **Phase 1** | ✅ Complete | 5/5 ✅ | Database schema with DeepFace columns |
| **Phase 2** | ✅ Complete | 5/5 ✅ | Configuration updates for DeepFace |
| **Phase 3** | ✅ Complete | 5/5 ✅ | Core face processing with DeepFace |
| **Phase 4** | ✅ Complete | 5/5 ✅ | GUI integration and metadata display |
| **Phase 5** | ✅ Complete | N/A | Dependencies and installation |
| **Phase 6** | ✅ Complete | 5/5 ✅ | Integration testing and validation |
**Total Tests:** 20/20 passing (100%)
---
## What Changed
### Before (face_recognition):
- 128-dimensional face encodings (dlib ResNet)
- HOG/CNN face detection
- Euclidean distance for matching
- Tuple location format: `(top, right, bottom, left)`
- No face confidence scores
- No detector/model metadata
### After (DeepFace):
- **512-dimensional face encodings** (ArcFace model)
- **RetinaFace detection** (state-of-the-art)
- **Cosine similarity** for matching
- **Dict location format:** `{'x': x, 'y': y, 'w': w, 'h': h}`
- **Face confidence scores** from detector
- **Detector/model metadata** stored and displayed
- **Multiple detector options:** RetinaFace, MTCNN, OpenCV, SSD
- **Multiple model options:** ArcFace, Facenet, Facenet512, VGG-Face
---
## Key Improvements
### Accuracy Improvements:
- ✅ **4x more detailed encodings** (512 vs 128 dimensions)
- ✅ **Better face detection** in difficult conditions
- ✅ **More robust to pose variations**
- ✅ **Better handling of partial faces**
- ✅ **Superior cross-age recognition**
- ✅ **Lower false positive rate**
### Feature Improvements:
- ✅ **Face confidence scores** displayed in GUI
- ✅ **Quality scores** for prioritizing best faces
- ✅ **Detector selection** in GUI (RetinaFace, MTCNN, etc.)
- ✅ **Model selection** in GUI (ArcFace, Facenet, etc.)
- ✅ **Metadata transparency** - see which detector/model was used
- ✅ **Configurable backends** for different speed/accuracy trade-offs
### Technical Improvements:
- ✅ **Modern deep learning stack** (TensorFlow, OpenCV)
- ✅ **Industry-standard metrics** (cosine similarity)
- ✅ **Better architecture** with clear separation of concerns
- ✅ **Comprehensive test coverage** (20 tests)
- ✅ **Full backward compatibility** (can read old location format)
---
## Test Results Summary
### Phase 1 Tests (Database Schema): 5/5 ✅
```
✅ Database Schema with DeepFace Columns
✅ Face Data Retrieval
✅ Location Format Handling
✅ FaceProcessor Configuration
✅ GUI Panel Compatibility
```
### Phase 2 Tests (Configuration): 5/5 ✅
```
✅ Config File Structure
✅ DeepFace Settings Present
✅ Default Values Correct
✅ Detector Options Available
✅ Model Options Available
```
### Phase 3 Tests (Core Processing): 5/5 ✅
```
✅ DeepFace Import
✅ DeepFace Detection
✅ Cosine Similarity
✅ Location Format Handling
✅ End-to-End Processing
```
### Phase 4 Tests (GUI Integration): 5/5 ✅
```
✅ Database Schema
✅ Face Data Retrieval
✅ Location Format Handling
✅ FaceProcessor Configuration
✅ GUI Panel Compatibility
```
### Phase 6 Tests (Integration): 5/5 ✅
```
✅ Face Detection
✅ Face Matching
✅ Metadata Storage
✅ Configuration
✅ Cosine Similarity
```
**Grand Total: 20/20 tests passing (100%)**
---
## Files Modified
### Core Files:
1. `src/core/database.py` - Added DeepFace columns to schema
2. `src/core/config.py` - Added DeepFace configuration settings
3. `src/core/face_processing.py` - Replaced face_recognition with DeepFace
4. `requirements.txt` - Updated dependencies
### GUI Files:
5. `src/gui/dashboard_gui.py` - Already had DeepFace settings UI
6. `src/gui/identify_panel.py` - Added metadata display
7. `src/gui/auto_match_panel.py` - Added metadata retrieval
8. `src/gui/modify_panel.py` - Added metadata retrieval
9. `src/gui/tag_manager_panel.py` - Fixed activation bug (bonus!)
### Test Files:
10. `tests/test_phase1_schema.py` - Phase 1 tests
11. `tests/test_phase2_config.py` - Phase 2 tests
12. `tests/test_phase3_deepface.py` - Phase 3 tests
13. `tests/test_phase4_gui.py` - Phase 4 tests
14. `tests/test_deepface_integration.py` - Integration tests
### Documentation:
15. `PHASE1_COMPLETE.md` - Phase 1 documentation
16. `PHASE2_COMPLETE.md` - Phase 2 documentation
17. `PHASE3_COMPLETE.md` - Phase 3 documentation
18. `PHASE4_COMPLETE.md` - Phase 4 documentation
19. `PHASE5_AND_6_COMPLETE.md` - Phases 5 & 6 documentation
20. `DEEPFACE_MIGRATION_COMPLETE_SUMMARY.md` - This document
### Migration:
21. `scripts/migrate_to_deepface.py` - Database migration script
---
## How to Use
### Processing Faces:
1. Open the dashboard: `python3 run_dashboard.py`
2. Click "🔍 Process" tab
3. Select **Detector** (e.g., RetinaFace)
4. Select **Model** (e.g., ArcFace)
5. Click "🚀 Start Processing"
### Identifying Faces:
1. Click "👤 Identify" tab
2. See face info with **detection confidence** and **quality scores**
3. Example: `Face 1 of 25 - photo.jpg | Detection: 95.0% | Quality: 85% | retinaface/ArcFace`
4. Identify faces as usual
### Viewing Metadata:
- **Identify panel:** Shows detection confidence, quality, detector/model
- **Database:** All metadata stored in faces table
- **Quality filtering:** Higher quality faces appear first
---
## Configuration Options
### Available Detectors:
- **retinaface** - Best accuracy, medium speed (recommended)
- **mtcnn** - Good accuracy, fast
- **opencv** - Fair accuracy, fastest
- **ssd** - Good accuracy, fast
### Available Models:
- **ArcFace** - Best accuracy, medium speed (recommended)
- **Facenet512** - Good accuracy, medium speed
- **Facenet** - Good accuracy, fast
- **VGG-Face** - Fair accuracy, fast
### Configuration File:
`src/core/config.py`:
```python
DEEPFACE_DETECTOR_BACKEND = "retinaface"
DEEPFACE_MODEL_NAME = "ArcFace"
DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace
```
---
## Performance Characteristics
### Speed:
- **Detection:** ~2-3x slower than face_recognition (worth it for accuracy!)
- **Matching:** Similar speed (cosine similarity is fast)
- **First Run:** Slow (downloads models ~100MB)
- **Subsequent Runs:** Normal speed (models cached)
### Resource Usage:
- **Memory:** ~500MB for TensorFlow/DeepFace
- **Disk:** ~1GB for models
- **CPU:** Moderate usage during processing
- **GPU:** Not yet utilized (future optimization)
### Encoding Storage:
- **Old:** 1,024 bytes per face (128 floats × 8 bytes)
- **New:** 4,096 bytes per face (512 floats × 8 bytes)
- **Impact:** 4x larger database, but significantly better accuracy
---
## Backward Compatibility
### ✅ Fully Compatible:
- Old location format (tuple) still works
- Database schema has default values for new columns
- Old queries continue to work (just don't get new metadata)
- API signatures unchanged (same method names)
- GUI panels handle both old and new data
### ⚠️ Not Compatible:
- Old 128-dim encodings cannot be compared with new 512-dim
- Database must be migrated (fresh start recommended)
- All faces need to be re-processed with DeepFace
### Migration Path:
```bash
# Backup current database (optional)
cp data/photos.db data/photos.db.backup
# Run migration script
python3 scripts/migrate_to_deepface.py
# Re-add photos and process with DeepFace
# (use dashboard GUI)
```
---
## Validation Checklist
### Core Functionality:
- [x] DeepFace successfully detects faces
- [x] 512-dimensional encodings generated
- [x] Cosine similarity calculates correctly
- [x] Face matching produces accurate results
- [x] Quality scores calculated properly
- [x] Adaptive tolerance works with DeepFace
### Database:
- [x] New columns created correctly
- [x] Encodings stored as 4096-byte BLOBs
- [x] Metadata (confidence, detector, model) stored
- [x] Queries work with new schema
- [x] Indices improve performance
### GUI:
- [x] All panels display faces correctly
- [x] Face thumbnails extract properly
- [x] Confidence scores display correctly
- [x] Detector/model selection works
- [x] Metadata displayed in identify panel
- [x] Tag Photos tab fixed (bonus!)
### Testing:
- [x] All 20 tests passing (100%)
- [x] Phase 1 tests pass (5/5)
- [x] Phase 2 tests pass (5/5)
- [x] Phase 3 tests pass (5/5)
- [x] Phase 4 tests pass (5/5)
- [x] Integration tests pass (5/5)
### Documentation:
- [x] Phase 1 documented
- [x] Phase 2 documented
- [x] Phase 3 documented
- [x] Phase 4 documented
- [x] Phases 5 & 6 documented
- [x] Complete summary created
- [x] Architecture updated
- [x] README updated
---
## Known Issues / Limitations
### Current:
1. **Processing Speed:** ~2-3x slower than face_recognition (acceptable trade-off)
2. **First Run:** Slow due to model downloads (~100MB)
3. **Memory Usage:** Higher due to TensorFlow (~500MB)
4. **No GPU Acceleration:** Not yet implemented (future enhancement)
### Future Enhancements:
- [ ] GPU acceleration for faster processing
- [ ] Batch processing for multiple images
- [ ] Model caching to reduce memory
- [ ] Multi-threading for parallel processing
- [ ] Face detection caching
---
## Success Metrics
### Achieved:
- ✅ **100% test coverage** - All 20 tests passing
- ✅ **Zero breaking changes** - Full backward compatibility
- ✅ **Zero linting errors** - Clean code throughout
- ✅ **Complete documentation** - All phases documented
- ✅ **Production ready** - Fully tested and validated
- ✅ **User-friendly** - GUI shows meaningful metadata
- ✅ **Configurable** - Multiple detector/model options
- ✅ **Safe migration** - Confirmation required before data loss
### Quality Metrics:
- **Test Pass Rate:** 100% (20/20)
- **Code Coverage:** High (all core functionality tested)
- **Documentation:** Complete (6 phase documents + summary)
- **Error Handling:** Comprehensive (graceful failures everywhere)
- **User Experience:** Enhanced (metadata display, quality indicators)
---
## Run All Tests
### Quick Validation:
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
# Run all phase tests
python3 tests/test_phase1_schema.py
python3 tests/test_phase2_config.py
python3 tests/test_phase3_deepface.py
python3 tests/test_phase4_gui.py
python3 tests/test_deepface_integration.py
```
### Expected Result:
```
All tests should show:
✅ PASS status
Tests passed: X/X (where X varies by test)
🎉 Success message at the end
```
---
## References
### Documentation:
- Migration Plan: `.notes/deepface_migration_plan.md`
- Architecture: `docs/ARCHITECTURE.md`
- README: `README.md`
### Phase Documentation:
- Phase 1: `PHASE1_COMPLETE.md`
- Phase 2: `PHASE2_COMPLETE.md`
- Phase 3: `PHASE3_COMPLETE.md`
- Phase 4: `PHASE4_COMPLETE.md`
- Phases 5 & 6: `PHASE5_AND_6_COMPLETE.md`
### Code:
- Database: `src/core/database.py`
- Config: `src/core/config.py`
- Face Processing: `src/core/face_processing.py`
- Dashboard: `src/gui/dashboard_gui.py`
### Tests:
- Phase 1 Test: `tests/test_phase1_schema.py`
- Phase 2 Test: `tests/test_phase2_config.py`
- Phase 3 Test: `tests/test_phase3_deepface.py`
- Phase 4 Test: `tests/test_phase4_gui.py`
- Integration Test: `tests/test_deepface_integration.py`
- Working Example: `tests/test_deepface_gui.py`
---
## What's Next?
The migration is **COMPLETE**! The system is production-ready.
### Optional Future Enhancements:
1. **Performance:**
- GPU acceleration
- Batch processing
- Multi-threading
2. **Features:**
- Age estimation
- Emotion detection
- Face clustering
3. **Testing:**
- Load testing
- Performance benchmarks
- More diverse test images
---
## Final Statistics
### Code Changes:
- **Files Modified:** 9 core files
- **Files Created:** 6 test files + 6 documentation files
- **Lines Added:** ~2,000+ lines (code + tests + docs)
- **Lines Modified:** ~300 lines in existing files
### Test Coverage:
- **Total Tests:** 20
- **Pass Rate:** 100% (20/20)
- **Test Lines:** ~1,500 lines of test code
- **Coverage:** All critical functionality tested
### Documentation:
- **Phase Docs:** 6 documents (~15,000 words)
- **Code Comments:** Comprehensive inline documentation
- **Test Documentation:** Clear test descriptions and output
- **User Guide:** Updated README and architecture docs
---
## Conclusion
The DeepFace migration is **100% COMPLETE** and **PRODUCTION READY**! 🎉
All 6 technical phases have been successfully implemented:
1. ✅ Database schema updated
2. ✅ Configuration migrated
3. ✅ Core processing replaced
4. ✅ GUI integrated
5. ✅ Dependencies managed
6. ✅ Testing completed
The PunimTag system now uses state-of-the-art DeepFace technology with:
- **Superior accuracy** (512-dim ArcFace encodings)
- **Modern architecture** (TensorFlow, OpenCV)
- **Rich metadata** (confidence scores, detector/model info)
- **Flexible configuration** (multiple detectors and models)
- **Comprehensive testing** (20/20 tests passing)
- **Full documentation** (complete phase documentation)
**The system is ready for production use!** 🚀
---
**Status:** ✅ COMPLETE
**Version:** 1.0
**Date:** October 16, 2025
**Author:** PunimTag Development Team
**Quality:** Production Ready
**🎉 Congratulations! The PunimTag DeepFace migration is COMPLETE! 🎉**

View File

@ -1,162 +0,0 @@
# 🎬 PunimTag Complete Demo Guide
## 🎯 Quick Client Demo (10 minutes)
**Perfect for:** Client presentations, showcasing enhanced face recognition features
---
## 🚀 Setup (2 minutes)
### 1. Prerequisites
```bash
cd /home/beast/Code/punimtag
source venv/bin/activate # Always activate first!
sudo apt install feh # Image viewer (one-time setup)
```
### 2. Prepare Demo
```bash
# Clean start
rm -f demo.db
# Check demo photos (should have 6+ photos with faces)
find demo_photos/ -name "*.jpg" -o -name "*.png" | wc -l
```
---
## 🎭 Client Demo Script (8 minutes)
### **Opening (30 seconds)**
*"I'll show you PunimTag - an enhanced face recognition tool that runs entirely on your local machine. It features visual face identification and intelligent cross-photo matching."*
### **Step 1: Scan & Process (2 minutes)**
```bash
# Scan photos
python3 photo_tagger.py scan demo_photos --recursive --db demo.db -v
# Process for faces
python3 photo_tagger.py process --db demo.db -v
# Show results
python3 photo_tagger.py stats --db demo.db
```
**Say:** *"Perfect! It found X photos and detected Y faces automatically."*
### **Step 2: Visual Face Identification (3 minutes)**
```bash
python3 photo_tagger.py identify --show-faces --batch 3 --db demo.db
```
**Key points to mention:**s
- *"Notice how it shows individual face crops - no guessing!"*
- *"Each face opens automatically in the image viewer"*
- *"You see exactly which person you're identifying"*
### **Step 3: Smart Auto-Matching (3 minutes)**
```bash
python3 photo_tagger.py auto-match --show-faces --db demo.db
```
**Key points to mention:**
- *"Watch how it finds the same people across different photos"*
- *"Side-by-side comparison with confidence scoring"*
- *"Only suggests logical cross-photo matches"*
- *"Color-coded confidence: Green=High, Yellow=Medium, Red=Low"*
### **Step 4: Search & Results (1 minute)**
```bash
# Search for identified person
python3 photo_tagger.py search "Alice" --db demo.db
# Final statistics
python3 photo_tagger.py stats --db demo.db
```
**Say:** *"Now you can instantly find all photos containing any person."*
---
## 🎯 Key Demo Points for Clients
**Privacy-First**: Everything runs locally, no cloud services
**Visual Interface**: See actual faces, not coordinates
**Intelligent Matching**: Cross-photo recognition with confidence scores
**Professional Quality**: Color-coded confidence, automatic cleanup
**Easy to Use**: Simple commands, clear visual feedback
**Fast & Efficient**: Batch processing, smart suggestions
---
## 🔧 Advanced Features (Optional)
### Confidence Control
```bash
# Strict matching (high confidence only)
python3 photo_tagger.py auto-match --tolerance 0.3 --show-faces --db demo.db
# Automatic high-confidence identification
python3 photo_tagger.py auto-match --auto --show-faces --db demo.db
```
### Twins Detection
```bash
# Include same-photo matching (for twins)
python3 photo_tagger.py auto-match --include-twins --show-faces --db demo.db
```
---
## 📊 Confidence Guide
| Level | Color | Description | Recommendation |
|-------|-------|-------------|----------------|
| 80%+ | 🟢 | Very High - Almost Certain | Accept confidently |
| 70%+ | 🟡 | High - Likely Match | Probably correct |
| 60%+ | 🟠 | Medium - Possible | Review carefully |
| 50%+ | 🔴 | Low - Questionable | Likely incorrect |
| <50% | | Very Low - Unlikely | Filtered out |
---
## 🚨 Demo Troubleshooting
**If no faces display:**
- Check feh installation: `sudo apt install feh`
- Manually open: `feh /tmp/face_*_crop.jpg`
**If no auto-matches:**
- Ensure same people appear in multiple photos
- Lower tolerance: `--tolerance 0.7`
**If confidence seems low:**
- 60-70% is normal for different lighting/angles
- 80%+ indicates excellent matches
---
## 🎪 Complete Demo Commands
```bash
# Full demo workflow
source venv/bin/activate
rm -f demo.db
python3 photo_tagger.py scan demo_photos --recursive --db demo.db -v
python3 photo_tagger.py process --db demo.db -v
python3 photo_tagger.py stats --db demo.db
python3 photo_tagger.py identify --show-faces --batch 3 --db demo.db
python3 photo_tagger.py auto-match --show-faces --db demo.db
python3 photo_tagger.py search "Alice" --db demo.db
python3 photo_tagger.py stats --db demo.db
```
**Or use the interactive script:**
```bash
./demo.sh
```
---
**🎉 Demo Complete!** Clients will see a professional-grade face recognition system with visual interfaces and intelligent matching capabilities.

View File

@ -34,13 +34,13 @@ This guide covers deployment of PunimTag to development and production environme
**Development Server:**
- **Host**: 10.0.10.121
- **User**: appuser
- **Password**: C0caC0la
- **Password**: [Contact administrator for password]
**Development Database:**
- **Host**: 10.0.10.181
- **Port**: 5432
- **User**: ladmin
- **Password**: C0caC0la
- **Password**: [Contact administrator for password]
---
@ -125,8 +125,8 @@ Set the following variables:
```bash
# Development Database
DATABASE_URL=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag_auth
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag_auth
# JWT Secrets (change in production!)
SECRET_KEY=dev-secret-key-change-in-production
@ -157,8 +157,8 @@ VITE_API_URL=http://10.0.10.121:8000
Create `viewer-frontend/.env`:
```bash
DATABASE_URL=postgresql://ladmin:C0caC0la@10.0.10.181:5432/punimtag
DATABASE_URL_AUTH=postgresql://ladmin:C0caC0la@10.0.10.181:5432/punimtag_auth
DATABASE_URL=postgresql://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag
DATABASE_URL_AUTH=postgresql://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag_auth
NEXTAUTH_URL=http://10.0.10.121:3001
NEXTAUTH_SECRET=dev-secret-key-change-in-production
```

File diff suppressed because it is too large Load Diff

496
docs/DEPLOY_FROM_SCRATCH.md Normal file
View File

@ -0,0 +1,496 @@
# Deploying PunimTag (From Scratch, Simple)
> **Deploying via CPanel?** See [`DEPLOY_CPANEL.md`](./DEPLOY_CPANEL.md) for CPanel-specific instructions.
This guide is for a **fresh install** where the databases do **not** need to be migrated.
You will start with **empty PostgreSQL databases** and deploy the app from a copy of the repo
(e.g., downloaded from **SharePoint**).
PunimTag is a monorepo with:
- **Backend**: FastAPI (`backend/`) on port **8000**
- **Admin**: React/Vite (`admin-frontend/`) on port **3000**
- **Viewer**: Next.js (`viewer-frontend/`) on port **3001**
- **Jobs**: Redis + RQ worker (`backend/worker.py`)
---
## Prerequisites (One-time)
On the server you deploy to, install:
- **Python 3.12+**
- **Node.js 18+** and npm
- **PostgreSQL 12+**
- **Redis 6+**
- **PM2** (`npm i -g pm2`)
Also make sure the server has:
- A path for uploaded photos (example: `/punimtag/data/uploads`)
- Network access to Postgres + Redis (local or remote)
### Quick install (Ubuntu/Debian)
```bash
sudo apt update
sudo apt install -y \
python3 python3-venv python3-pip \
nodejs npm \
postgresql-client \
redis-server
sudo systemctl enable --now redis-server
redis-cli ping
# PM2 (process manager)
sudo npm i -g pm2
```
Notes:
- If you manage Postgres on a separate host, you only need `postgresql-client` on this server.
- If you install Postgres locally, install `postgresql` (server) too, not just the client.
### Firewall Rules (One-time setup)
Configure firewall to allow access to the application ports:
```bash
sudo ufw allow 3000/tcp # Admin frontend
sudo ufw allow 3001/tcp # Viewer frontend
sudo ufw allow 8000/tcp # Backend API
```
### PostgreSQL Remote Connection Setup (if using remote database)
If your PostgreSQL database is on a **separate server** from the application, you need to configure PostgreSQL to accept remote connections.
**On the PostgreSQL database server:**
1. **Edit `pg_hba.conf`** to allow connections from your application server:
```bash
sudo nano /etc/postgresql/*/main/pg_hba.conf
```
Add a line allowing connections from your application server IP:
```bash
# Allow connections from application server
host all all 10.0.10.121/32 md5
```
Replace `10.0.10.121` with your actual application server IP address.
Replace `md5` with `scram-sha-256` if your PostgreSQL version uses that (PostgreSQL 14+).
2. **Edit `postgresql.conf`** to listen on network interfaces:
```bash
sudo nano /etc/postgresql/*/main/postgresql.conf
```
Find and update the `listen_addresses` setting:
```bash
listen_addresses = '*' # Listen on all interfaces
# OR for specific IP:
# listen_addresses = 'localhost,10.0.10.181' # Replace with your DB server IP
```
3. **Restart PostgreSQL** to apply changes:
```bash
sudo systemctl restart postgresql
```
4. **Configure firewall** on the database server to allow PostgreSQL connections:
```bash
sudo ufw allow from 10.0.10.121 to any port 5432 # Replace with your app server IP
# OR allow from all (less secure):
# sudo ufw allow 5432/tcp
```
5. **Test the connection** from the application server:
```bash
psql -h 10.0.10.181 -U punim_dev_user -d postgres
```
Replace `10.0.10.181` with your database server IP and `punim_dev_user` with your database username.
**Note:** If PostgreSQL is on the same server as the application, you can skip this step and use `localhost` in your connection strings.
---
## Fast path (recommended): run the deploy script
On Ubuntu/Debian you can do most of the setup with one script:
```bash
cd /opt/punimtag
chmod +x scripts/deploy_from_scratch.sh
./scripts/deploy_from_scratch.sh
```
The script will:
- Install system packages (including Redis)
- Configure firewall rules (optional, with prompt)
- Prompt for PostgreSQL remote connection setup (if using remote database)
- Copy `*_example` env files to real `.env` files (if missing)
- Install Python + Node dependencies
- Generate Prisma clients for the viewer
- Create auth DB tables and admin user (idempotent)
- Build frontend applications for production
- Configure PM2 (copy ecosystem.config.js from example if needed)
- Start services with PM2
If you prefer manual steps, continue below.
## Step 1 — Put the code on the server
If you received the code via SharePoint:
1. Download the repo ZIP from SharePoint.
2. Copy it to the server (SCP/SFTP).
3. Extract it into a stable path (example used below):
```bash
sudo mkdir -p /opt/punimtag
sudo chown -R $USER:$USER /opt/punimtag
# then extract/copy the repository contents into /opt/punimtag
```
---
## Step 2 — Create environment files (rename `_example` → real)
### 2.1 Root env: `/opt/punimtag/.env`
1. Copy and rename:
```bash
cd /opt/punimtag
cp .env_example .env
```
2. Edit `.env` and set the real values. The template includes **at least**:
```bash
# PostgreSQL (main database)
DATABASE_URL=postgresql+psycopg2://USER:PASSWORD@HOST:5432/punimtag
# PostgreSQL (auth database)
DATABASE_URL_AUTH=postgresql+psycopg2://USER:PASSWORD@HOST:5432/punimtag_auth
# JWT / admin bootstrap (change these!)
SECRET_KEY=change-me
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
# Photo uploads storage
PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads
# Redis (background jobs)
REDIS_URL=redis://127.0.0.1:6379/0
```
**Important:** If using a **remote PostgreSQL server**, ensure you've completed the "PostgreSQL Remote Connection Setup" steps in the Prerequisites section above before configuring these connection strings.
Notes:
- The backend **auto-creates tables** on first run if they are missing.
- The backend will also attempt to create the databases **if** the configured Postgres user has
privileges (otherwise create the DBs manually).
### 2.2 Admin env: `/opt/punimtag/admin-frontend/.env`
1. Copy and rename:
```bash
cd /opt/punimtag/admin-frontend
cp .env_example .env
```
2. Edit `.env`:
**For direct access (no reverse proxy):**
```bash
VITE_API_URL=http://YOUR_SERVER_IP_OR_DOMAIN:8000
```
**For reverse proxy setup (HTTPS via Caddy/nginx):**
```bash
# Leave empty to use relative paths - API calls will go through the same proxy
VITE_API_URL=
```
**Important:** When using a reverse proxy (Caddy/nginx) with HTTPS, set `VITE_API_URL` to empty. This allows the frontend to use relative API paths that work correctly with the proxy, avoiding mixed content errors.
### 2.3 Viewer env: `/opt/punimtag/viewer-frontend/.env`
1. Copy and rename:
```bash
cd /opt/punimtag/viewer-frontend
cp .env_example .env
```
2. Edit `.env`:
```bash
# Main DB (same as backend, but Prisma URL format)
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/punimtag
# Auth DB (same as backend, but Prisma URL format)
DATABASE_URL_AUTH=postgresql://USER:PASSWORD@HOST:5432/punimtag_auth
# Optional write-capable DB user (falls back to DATABASE_URL if not set)
# DATABASE_URL_WRITE=postgresql://USER:PASSWORD@HOST:5432/punimtag
# NextAuth
NEXTAUTH_URL=http://YOUR_SERVER_IP_OR_DOMAIN:3001
NEXTAUTH_SECRET=change-me
AUTH_URL=http://YOUR_SERVER_IP_OR_DOMAIN:3001
```
---
## Step 3 — Install dependencies
From the repo root:
```bash
cd /opt/punimtag
# Backend venv
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
# Frontends
cd admin-frontend
npm ci
cd ../viewer-frontend
npm ci
```
---
## Step 4 — Initialize the viewer Prisma clients
The viewer uses Prisma clients for both DBs.
```bash
cd /opt/punimtag/viewer-frontend
npm run prisma:generate:all
```
---
## Step 5 — Create the auth DB tables + admin user
The auth DB schema is set up by the viewer scripts.
```bash
cd /opt/punimtag/viewer-frontend
# Creates required auth tables / columns (idempotent)
npx tsx scripts/setup-auth.ts
# Ensures an admin user exists (idempotent)
npx tsx scripts/fix-admin-user.ts
```
---
## Step 6 — Build frontends
Build the frontend applications for production:
```bash
# Admin frontend
cd /opt/punimtag/admin-frontend
npm run build
# Viewer frontend
cd /opt/punimtag/viewer-frontend
npm run build
```
Note: The admin frontend build creates a `dist/` directory that will be served by PM2.
The viewer frontend build creates an optimized Next.js production build.
---
## Step 7 — Configure PM2
This repo includes a PM2 config template. If `ecosystem.config.js` doesn't exist, copy it from the example:
```bash
cd /opt/punimtag
cp ecosystem.config.js.example ecosystem.config.js
```
Edit `ecosystem.config.js` and update:
- All `cwd` paths to your deployment directory (e.g., `/opt/punimtag`)
- All `error_file` and `out_file` paths to your user's home directory
- `PYTHONPATH` and `PATH` environment variables to match your deployment paths
---
## Step 8 — Start the services (PM2)
Start all services using PM2:
```bash
cd /opt/punimtag
pm2 start ecosystem.config.js
pm2 save
```
Optional (auto-start on reboot):
```bash
pm2 startup
```
---
## Step 9 — First-run DB initialization (automatic)
On first startup, the backend will connect to Postgres and create missing tables automatically.
To confirm:
```bash
curl -sS http://127.0.0.1:8000/api/v1/health
```
Viewer health check (verifies DB permissions):
```bash
curl -sS http://127.0.0.1:3001/api/health
```
---
## Step 10 — Open the apps
- **Admin**: `http://YOUR_SERVER:3000`
- **Viewer**: `http://YOUR_SERVER:3001`
- **API docs**: `http://YOUR_SERVER:8000/docs`
---
## Step 11 — Reverse Proxy Setup (HTTPS via Caddy/nginx)
If you're using a reverse proxy (Caddy, nginx, etc.) to serve the application over HTTPS, configure it to route `/api/*` requests to the backend **before** serving static files.
The proxy must forward `/api/*` requests to the backend (port 8000) **before** trying to serve static files.
#### Caddy Configuration
Update your Caddyfile on the proxy server:
```caddyfile
your-admin-domain.com {
import security-headers
# CRITICAL: Route SSE streaming endpoints FIRST with no buffering
# This is required for Server-Sent Events (EventSource) to work properly
handle /api/v1/jobs/stream/* {
reverse_proxy http://YOUR_BACKEND_IP:8000 {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Disable buffering for SSE streams
flush_interval -1
}
}
# CRITICAL: Route API requests to backend (before static files)
handle /api/* {
reverse_proxy http://YOUR_BACKEND_IP:8000 {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# Proxy everything else to the frontend
reverse_proxy http://YOUR_BACKEND_IP:3000
}
```
**Important:** The `handle /api/*` block **must come before** the general `reverse_proxy` directive.
After updating:
```bash
# Test configuration
caddy validate --config /path/to/Caddyfile
# Reload Caddy
sudo systemctl reload caddy
```
#### Nginx Configuration
```nginx
server {
listen 80;
server_name your-admin-domain.com;
root /opt/punimtag/admin-frontend/dist;
index index.html;
# CRITICAL: API proxy must come FIRST, before static file location
location /api {
proxy_pass http://YOUR_BACKEND_IP: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;
}
# Serve static files for everything else
location / {
try_files $uri $uri/ /index.html;
}
}
```
After updating:
```bash
# Test configuration
sudo nginx -t
# Reload nginx
sudo systemctl reload nginx
```
### Environment Variable Setup
When using a reverse proxy, ensure `admin-frontend/.env` has:
```bash
VITE_API_URL=
```
This allows the frontend to use relative API paths (`/api/v1/...`) that work correctly with the proxy.
---
## Common fixes
### API requests return HTML instead of JSON
1. Ensure your reverse proxy (Caddy/nginx) routes `/api/*` requests to the backend **before** serving static files (see Step 11 above).
2. Verify `admin-frontend/.env` has `VITE_API_URL=` (empty) when using a proxy.
3. Rebuild the frontend after changing `.env`: `cd admin-frontend && npm run build && pm2 restart punimtag-admin`
### Viewer `/api/health` says permission denied
Run the provided grant script on the DB server (as a privileged Postgres user):
- `viewer-frontend/grant_readonly_permissions.sql`
### Logs
```bash
pm2 status
pm2 logs punimtag-api --lines 200
pm2 logs punimtag-viewer --lines 200
pm2 logs punimtag-admin --lines 200
pm2 logs punimtag-worker --lines 200
```

View File

@ -1,56 +0,0 @@
# Face Detection Improvements
## Problem
The face detection system was incorrectly identifying balloons, buffet tables, and other decorative objects as faces, leading to false positives in the identification process.
## Root Cause
The face detection filtering was too permissive:
- Low confidence threshold (40%)
- Small minimum face size (40 pixels)
- Loose aspect ratio requirements
- No additional filtering for edge cases
## Solution Implemented
### 1. Stricter Configuration Settings
Updated `/src/core/config.py`:
- **MIN_FACE_CONFIDENCE**: Increased from 0.4 (40%) to 0.7 (70%)
- **MIN_FACE_SIZE**: Increased from 40 to 60 pixels
- **MAX_FACE_SIZE**: Reduced from 2000 to 1500 pixels
### 2. Enhanced Face Validation Logic
Improved `/src/core/face_processing.py` in `_is_valid_face_detection()`:
- **Stricter aspect ratio**: Changed from 0.3-3.0 to 0.4-2.5
- **Size-based confidence requirements**: Small faces (< 100x100 pixels) require 80% confidence
- **Edge detection filtering**: Faces near image edges require 85% confidence
- **Better error handling**: More robust validation logic
### 3. False Positive Cleanup
Created `/scripts/cleanup_false_positives.py`:
- Removes existing false positives from database
- Applies new filtering criteria to existing faces
- Successfully removed 199 false positive faces
## Results
- **Before**: 301 unidentified faces (many false positives)
- **After**: 102 unidentified faces (cleaned up false positives)
- **Removed**: 199 false positive faces (66% reduction)
## Usage
1. **Clean existing false positives**: `python scripts/cleanup_false_positives.py`
2. **Process new photos**: Use the dashboard with improved filtering
3. **Monitor results**: Check the Identify panel for cleaner face detection
## Technical Details
The improvements focus on:
- **Confidence thresholds**: Higher confidence requirements reduce false positives
- **Size filtering**: Larger minimum sizes filter out small decorative objects
- **Aspect ratio**: Stricter ratios ensure face-like proportions
- **Edge detection**: Faces near edges often indicate false positives
- **Quality scoring**: Better quality assessment for face validation
## Future Considerations
- Monitor detection accuracy with real faces
- Adjust thresholds based on user feedback
- Consider adding face landmark detection for additional validation
- Implement user feedback system for false positive reporting

View File

@ -1,72 +0,0 @@
# Face Recognition Migration - Complete
## ✅ Migration Status: 100% Complete
All remaining `face_recognition` library usage has been successfully replaced with DeepFace implementation.
## 🔧 Fixes Applied
### 1. **Critical Fix: Face Distance Calculation**
**File**: `/src/core/face_processing.py` (Line 744)
- **Before**: `distance = face_recognition.face_distance([unid_enc], person_enc)[0]`
- **After**: `distance = self._calculate_cosine_similarity(unid_enc, person_enc)`
- **Impact**: Now uses DeepFace's cosine similarity instead of face_recognition's distance metric
- **Method**: `find_similar_faces()` - core face matching functionality
### 2. **Installation Test Update**
**File**: `/src/setup.py` (Lines 86-94)
- **Before**: Imported `face_recognition` for installation testing
- **After**: Imports `DeepFace`, `tensorflow`, and other DeepFace dependencies
- **Impact**: Installation test now validates DeepFace setup instead of face_recognition
### 3. **Comment Update**
**File**: `/src/photo_tagger.py` (Line 298)
- **Before**: "Suppress pkg_resources deprecation warning from face_recognition library"
- **After**: "Suppress TensorFlow and other deprecation warnings from DeepFace dependencies"
- **Impact**: Updated comment to reflect current technology stack
## 🧪 Verification Results
### ✅ **No Remaining face_recognition Usage**
- **Method calls**: 0 found
- **Imports**: 0 found
- **Active code**: 100% DeepFace
### ✅ **Installation Test Passes**
```
🧪 Testing DeepFace face recognition installation...
✅ All required modules imported successfully
```
### ✅ **Dependencies Clean**
- `requirements.txt`: Only DeepFace dependencies
- No face_recognition in any configuration files
- All imports use DeepFace libraries
## 📊 **Migration Summary**
| Component | Status | Notes |
|-----------|--------|-------|
| Face Detection | ✅ DeepFace | RetinaFace detector |
| Face Encoding | ✅ DeepFace | ArcFace model (512-dim) |
| Face Matching | ✅ DeepFace | Cosine similarity |
| Installation | ✅ DeepFace | Tests DeepFace setup |
| Configuration | ✅ DeepFace | All settings updated |
| Documentation | ✅ DeepFace | Comments updated |
## 🎯 **Benefits Achieved**
1. **Consistency**: All face operations now use the same DeepFace technology stack
2. **Performance**: Better accuracy with ArcFace model and RetinaFace detector
3. **Maintainability**: Single technology stack reduces complexity
4. **Future-proof**: DeepFace is actively maintained and updated
## 🚀 **Next Steps**
The migration is complete! The application now:
- Uses DeepFace exclusively for all face operations
- Has improved face detection filtering (reduced false positives)
- Maintains consistent similarity calculations throughout
- Passes all installation and functionality tests
**Ready for production use with DeepFace technology stack.**

View File

@ -0,0 +1,140 @@
# Fix Admin Login WordPress Redirect
## Problem
Login redirects to WordPress (`wp-login.php`) instead of calling FastAPI.
## Root Cause
The admin frontend is calling `/api/v1/auth/login` (relative path) which WordPress intercepts. It should call `/punim-api/api/v1/auth/login`.
This happens because **Vite environment variables are injected at BUILD TIME**, not runtime. If you changed `.env` but didn't rebuild, the old code is still running.
## Solution
### Step 1: Verify Environment Variable
```bash
cd ~/punimtag/admin-frontend
cat .env | grep VITE_API_URL
```
Should show:
```
VITE_API_URL=/punim-api
```
If missing or wrong:
```bash
nano admin-frontend/.env
# Add: VITE_API_URL=/punim-api
# Save: Ctrl+X, Y, Enter
```
### Step 2: REBUILD Admin Frontend (CRITICAL!)
**This is required!** Vite reads `.env` during build:
```bash
cd ~/punimtag/admin-frontend
npm run build
```
Wait for completion. You should see:
```
✓ built in X.XXs
```
### Step 3: Verify Build Contains Correct URL
```bash
cd ~/punimtag/admin-frontend
grep -r "punim-api" dist/ | head -3
```
Should show `/punim-api` in JavaScript files.
### Step 4: Restart Admin Service
```bash
pm2 restart punimtag-admin
```
### Step 5: Clear Browser Cache
- **Hard refresh:** `Ctrl+Shift+R` (Windows/Linux) or `Cmd+Shift+R` (Mac)
- Or open DevTools → Network tab → check "Disable cache"
### Step 6: Verify Request URL
1. Open admin frontend: `https://jrccphotos.org/punim-admin/`
2. Open DevTools → Network tab
3. Try to login
4. Check the POST request URL:
- ✅ **Correct:** `https://jrccphotos.org/punim-api/api/v1/auth/login`
- ❌ **Wrong:** `https://jrccphotos.org/api/v1/auth/login` (means rebuild didn't work)
## Why This Happens
Vite environment variables work like this:
1. **Build time:** Vite reads `VITE_API_URL` from `.env` and replaces `import.meta.env.VITE_API_URL` in code
2. **Runtime:** The built JavaScript has the actual value hardcoded
If you change `.env` but don't rebuild:
- Old build still has old value
- New `.env` value is ignored
- WordPress intercepts relative paths
## Troubleshooting
### Still redirects to WordPress after rebuild?
1. **Check if dist/ folder was updated:**
```bash
ls -lt ~/punimtag/admin-frontend/dist/ | head -5
```
Should show recent timestamps.
2. **Check PM2 is serving from dist/:**
```bash
pm2 logs punimtag-admin --lines 20
```
Should show it's serving from `dist/` directory.
3. **Check serve.sh script:**
```bash
cat ~/punimtag/admin-frontend/serve.sh
```
Should serve from `dist/` directory.
4. **Verify VITE_API_URL in built code:**
```bash
cd ~/punimtag/admin-frontend
grep -o "punim-api" dist/assets/*.js | head -1
```
Should find `/punim-api` in the built files.
### Build fails?
- Check Node.js version: `node --version` (need 18+)
- Check npm install: `cd admin-frontend && npm install`
- Check for errors: `npm run build 2>&1 | tail -20`
## Quick Checklist
- [ ] `VITE_API_URL=/punim-api` in `admin-frontend/.env`
- [ ] Ran `npm run build` in `admin-frontend/` directory
- [ ] Build completed successfully (no errors)
- [ ] Verified `/punim-api` appears in `dist/` files
- [ ] Restarted PM2: `pm2 restart punimtag-admin`
- [ ] Cleared browser cache (hard refresh)
- [ ] Checked browser Network tab - request goes to `/punim-api/api/v1/auth/login`
---
**Remember:** Every time you change `VITE_API_URL` in `.env`, you MUST rebuild!

View File

@ -0,0 +1,124 @@
# Fix Login Redirect Bug - Client Server
## Problem
When login fails (401 error), the code redirects to `/login` which WordPress intercepts. It should redirect to `/punim-admin/login`.
## Root Cause
In `admin-frontend/src/api/client.ts`, the error handler uses:
```typescript
window.location.href = '/login' // ❌ Wrong - goes to domain root
```
This should be:
```typescript
window.location.href = '/punim-admin/login' // ✅ Correct
```
## Fix on Client Server
### Option 1: Quick Fix (Edit Built File - Temporary)
**⚠️ Warning:** This will be overwritten on next rebuild!
```bash
cd ~/punimtag/admin-frontend/dist/assets
# Find the JavaScript file
JS_FILE=$(ls index-*.js | head -1)
# Backup
cp "$JS_FILE" "$JS_FILE.backup"
# Replace /login with /punim-admin/login (only in redirect contexts)
sed -i 's|window.location.href="/login"|window.location.href="/punim-admin/login"|g' "$JS_FILE"
sed -i "s|window.location.href='/login'|window.location.href='/punim-admin/login'|g" "$JS_FILE"
# Restart
pm2 restart punimtag-admin
```
### Option 2: Fix Source Code (Permanent)
Edit the source file on client server:
```bash
cd ~/punimtag/admin-frontend/src/api
nano client.ts
```
Find these lines (around lines 44 and 65):
```typescript
window.location.href = '/login'
```
Replace with:
```typescript
window.location.href = '/punim-admin/login'
```
Then rebuild:
```bash
cd ~/punimtag/admin-frontend
npm run build
pm2 restart punimtag-admin
```
## Verification
After fix:
1. Try to login with wrong credentials
2. Should stay on admin frontend (show error message)
3. Should NOT redirect to WordPress
4. Check browser URL - should be `https://jrccphotos.org/punim-admin/login`
## What to Change
In `admin-frontend/src/api/client.ts`, change:
**Line ~44:**
```typescript
// OLD:
window.location.href = '/login'
// NEW:
window.location.href = '/punim-admin/login'
```
**Line ~65:**
```typescript
// OLD:
window.location.href = '/login'
// NEW:
window.location.href = '/punim-admin/login'
```
**Line ~42 (check):**
```typescript
// OLD:
const isLoginPage = window.location.pathname === '/login'
// NEW:
const isLoginPage = window.location.pathname === '/punim-admin/login' || window.location.pathname === '/punim-admin/'
```
## Better Solution (Future)
Use a helper function to get the base path dynamically:
```typescript
const getBasePath = () => {
const path = window.location.pathname;
if (path.startsWith('/punim-admin')) return '/punim-admin';
return '';
};
window.location.href = `${getBasePath()}/login`;
```
But for now, the hardcoded `/punim-admin/login` fix will work.

View File

@ -1,233 +0,0 @@
# Folder Picker Analysis - Getting Full Paths
## Problem
Browsers don't expose full file system paths for security reasons. Current implementation only gets folder names, not full absolute paths.
## Current Limitations
### Browser-Based Solutions (Current)
1. **File System Access API** (`showDirectoryPicker`)
- ✅ No confirmation dialog
- ❌ Only returns folder name, not full path
- ❌ Only works in Chrome 86+, Edge 86+, Opera 72+
2. **webkitdirectory input**
- ✅ Works in all browsers
- ❌ Shows security confirmation dialog
- ❌ Only returns relative paths, not absolute paths
## Alternative Solutions
### ✅ **Option 1: Backend API with Tkinter (RECOMMENDED)**
**How it works:**
- Frontend calls backend API endpoint
- Backend uses `tkinter.filedialog.askdirectory()` to show native folder picker
- Backend returns full absolute path to frontend
- Frontend populates the path input
**Pros:**
- ✅ Returns full absolute path
- ✅ Native OS dialog (looks native on Windows/Linux/macOS)
- ✅ No browser security restrictions
- ✅ tkinter already used in project
- ✅ Cross-platform support
- ✅ No confirmation dialogs
**Cons:**
- ⚠️ Requires backend to be running on same machine as user
- ⚠️ Backend needs GUI access (tkinter requires display)
- ⚠️ May need X11 forwarding for remote servers
**Implementation:**
```python
# Backend API endpoint
@router.post("/browse-folder")
def browse_folder() -> dict:
"""Open native folder picker and return selected path."""
import tkinter as tk
from tkinter import filedialog
# Create root window (hidden)
root = tk.Tk()
root.withdraw() # Hide main window
root.attributes('-topmost', True) # Bring to front
# Show folder picker
folder_path = filedialog.askdirectory(
title="Select folder to scan",
mustexist=True
)
root.destroy()
if folder_path:
return {"path": folder_path, "success": True}
else:
return {"path": "", "success": False, "message": "No folder selected"}
```
```typescript
// Frontend API call
const browseFolder = async (): Promise<string | null> => {
const { data } = await apiClient.post<{path: string, success: boolean}>(
'/api/v1/photos/browse-folder'
)
return data.success ? data.path : null
}
```
---
### **Option 2: Backend API with PyQt/PySide**
**How it works:**
- Similar to Option 1, but uses PyQt/PySide instead of tkinter
- More modern UI, but requires additional dependency
**Pros:**
- ✅ Returns full absolute path
- ✅ More modern-looking dialogs
- ✅ Better customization options
**Cons:**
- ❌ Requires additional dependency (PyQt5/PyQt6/PySide2/PySide6)
- ❌ Larger package size
- ❌ Same GUI access requirements as tkinter
---
### **Option 3: Backend API with Platform-Specific Tools**
**How it works:**
- Use platform-specific command-line tools to open folder pickers
- Windows: PowerShell script
- Linux: `zenity`, `kdialog`, or `yad`
- macOS: AppleScript
**Pros:**
- ✅ Returns full absolute path
- ✅ No GUI framework required
- ✅ Works on headless servers with X11 forwarding
**Cons:**
- ❌ Platform-specific code required
- ❌ Requires external tools to be installed
- ❌ More complex implementation
- ❌ Less consistent UI across platforms
**Example (Linux with zenity):**
```python
import subprocess
import platform
def browse_folder_zenity():
result = subprocess.run(
['zenity', '--file-selection', '--directory'],
capture_output=True,
text=True
)
return result.stdout.strip() if result.returncode == 0 else None
```
---
### **Option 4: Electron App (Not Applicable)**
**How it works:**
- Convert web app to Electron app
- Use Electron's `dialog.showOpenDialog()` API
**Pros:**
- ✅ Returns full absolute path
- ✅ Native OS dialogs
- ✅ No browser restrictions
**Cons:**
- ❌ Requires complete app restructuring
- ❌ Not applicable (this is a web app, not Electron)
- ❌ Much larger application size
---
### **Option 5: Custom File Browser UI**
**How it works:**
- Build custom file browser in React
- Backend API provides directory listings
- User navigates through folders in UI
- Select folder when found
**Pros:**
- ✅ Full control over UI/UX
- ✅ Can show full paths
- ✅ No native dialogs needed
**Cons:**
- ❌ Complex implementation
- ❌ Requires multiple API calls
- ❌ Slower user experience
- ❌ Need to handle permissions, hidden files, etc.
---
## Recommendation
**✅ Use Option 1: Backend API with Tkinter**
This is the best solution because:
1. **tkinter is already used** in the project (face_processing.py)
2. **Simple implementation** - just one API endpoint
3. **Returns full paths** - solves the core problem
4. **Native dialogs** - familiar to users
5. **No additional dependencies** - tkinter is built into Python
6. **Cross-platform** - works on Windows, Linux, macOS
### Implementation Steps
1. **Create backend API endpoint** (`/api/v1/photos/browse-folder`)
- Use `tkinter.filedialog.askdirectory()`
- Return selected path as JSON
2. **Add frontend API method**
- Call the new endpoint
- Handle response and populate path input
3. **Update Browse button handler**
- Call backend API instead of browser picker
- Show loading state while waiting
- Handle errors gracefully
4. **Fallback option**
- Keep browser-based picker as fallback
- Use if backend API fails or unavailable
### Considerations
- **Headless servers**: If backend runs on headless server, need X11 forwarding or use Option 3 (platform-specific tools)
- **Remote access**: If users access from remote machines, backend must be on same machine as user
- **Error handling**: Handle cases where tkinter dialog can't be shown (no display, permissions, etc.)
---
## Quick Comparison Table
| Solution | Full Path | Native Dialog | Dependencies | Complexity | Recommended |
|----------|-----------|---------------|--------------|------------|-------------|
| **Backend + Tkinter** | ✅ | ✅ | None (built-in) | Low | ✅ **YES** |
| Backend + PyQt | ✅ | ✅ | PyQt/PySide | Medium | ⚠️ Maybe |
| Platform Tools | ✅ | ✅ | zenity/kdialog/etc | High | ⚠️ Maybe |
| Custom UI | ✅ | ❌ | None | Very High | ❌ No |
| Electron | ✅ | ✅ | Electron | Very High | ❌ No |
| Browser API | ❌ | ✅ | None | Low | ❌ No |
---
## Next Steps
1. Implement backend API endpoint with tkinter
2. Add frontend API method
3. Update Browse button to use backend API
4. Add error handling and fallback
5. Test on all platforms (Windows, Linux, macOS)

View File

@ -1,166 +0,0 @@
# Identify Panel Fixes
**Date:** October 16, 2025
**Status:** ✅ Complete
## Issues Fixed
### 1. ✅ Unique Checkbox Default State
**Issue:** User requested that the "Unique faces only" checkbox be unchecked by default.
**Status:** Already correct! The checkbox was already unchecked by default.
**Code Location:** `src/gui/identify_panel.py`, line 76
```python
self.components['unique_var'] = tk.BooleanVar() # Defaults to False (unchecked)
```
### 2. ✅ Quality Filter Not Working
**Issue:** The "Min quality" filter slider wasn't actually filtering faces when loading them from the database.
**Root Cause:**
- The quality filter value was being captured in the GUI (slider with 0-100% range)
- However, the `_get_unidentified_faces()` method wasn't using this filter when querying the database
- Quality filtering was only happening during navigation (Back/Next buttons), not during initial load
**Solution:**
1. Modified `_get_unidentified_faces()` to accept a `min_quality_score` parameter
2. Added SQL WHERE clause to filter by quality score: `AND f.quality_score >= ?`
3. Updated all 4 calls to `_get_unidentified_faces()` to pass the quality filter value:
- `_start_identification()` - Initial load
- `on_unique_change()` - When toggling unique faces filter
- `_load_more_faces()` - Loading additional batches
- `_apply_date_filters()` - When applying date filters
**Code Changes:**
**File:** `src/gui/identify_panel.py`
**Modified Method Signature (line 519-521):**
```python
def _get_unidentified_faces(self, batch_size: int, date_from: str = None, date_to: str = None,
date_processed_from: str = None, date_processed_to: str = None,
min_quality_score: float = 0.0) -> List[Tuple]:
```
**Added SQL Filter (lines 537-540):**
```python
# Add quality filtering if specified
if min_quality_score > 0.0:
query += ' AND f.quality_score >= ?'
params.append(min_quality_score)
```
**Updated Call Sites:**
1. **`_start_identification()` (lines 494-501):**
```python
# Get quality filter
min_quality = self.components['quality_filter_var'].get()
min_quality_score = min_quality / 100.0
# Get unidentified faces with quality filter
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
date_processed_from, date_processed_to,
min_quality_score)
```
2. **`on_unique_change()` (lines 267-274):**
```python
# Get quality filter
min_quality = self.components['quality_filter_var'].get()
min_quality_score = min_quality / 100.0
# Reload faces with current filters
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
date_processed_from, date_processed_to,
min_quality_score)
```
3. **`_load_more_faces()` (lines 1378-1385):**
```python
# Get quality filter
min_quality = self.components['quality_filter_var'].get()
min_quality_score = min_quality / 100.0
# Get more faces
more_faces = self._get_unidentified_faces(DEFAULT_BATCH_SIZE, date_from, date_to,
date_processed_from, date_processed_to,
min_quality_score)
```
4. **`_apply_date_filters()` (lines 1575-1581):**
```python
# Quality filter is already extracted above in min_quality
min_quality_score = min_quality / 100.0
# Reload faces with new filters
self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
date_processed_from, date_processed_to,
min_quality_score)
```
## Testing
**Syntax Check:** ✅ Passed
```bash
python3 -m py_compile src/gui/identify_panel.py
```
**Linter Check:** ✅ No errors found
## How Quality Filter Now Works
1. **User adjusts slider:** Sets quality from 0% to 100% (in 5% increments)
2. **User clicks "Start Identification":**
- Gets quality value (e.g., 75%)
- Converts to 0.0-1.0 scale (e.g., 0.75)
- Passes to `_get_unidentified_faces()`
- SQL query filters: `WHERE f.quality_score >= 0.75`
- Only faces with quality ≥ 75% are loaded
3. **Quality filter persists:**
- When loading more batches
- When toggling unique faces
- When applying date filters
- When navigating (Back/Next already had quality filtering)
## Expected Behavior
### Quality Filter = 0% (default)
- Shows all faces regardless of quality
- SQL: No quality filter applied
### Quality Filter = 50%
- Shows only faces with quality ≥ 50%
- SQL: `WHERE f.quality_score >= 0.5`
### Quality Filter = 75%
- Shows only faces with quality ≥ 75%
- SQL: `WHERE f.quality_score >= 0.75`
### Quality Filter = 100%
- Shows only perfect quality faces
- SQL: `WHERE f.quality_score >= 1.0`
## Notes
- The quality score is stored in the database as a float between 0.0 and 1.0
- The GUI displays it as a percentage (0-100%) for user-friendliness
- The conversion happens at every call site: `min_quality_score = min_quality / 100.0`
- The Back/Next navigation already had quality filtering logic via `_find_next_qualifying_face()` - this continues to work as before
## Files Modified
- `src/gui/identify_panel.py` (1 file, ~15 lines changed)
## Validation Checklist
- [x] Quality filter parameter added to method signature
- [x] SQL WHERE clause added for quality filtering
- [x] All 4 call sites updated with quality filter
- [x] Syntax validation passed
- [x] No linter errors
- [x] Unique checkbox already defaults to unchecked
- [x] Code follows PEP 8 style guidelines
- [x] Changes are backward compatible (min_quality_score defaults to 0.0)

View File

@ -1,229 +0,0 @@
# Import Statements Fix Summary
**Date**: October 15, 2025
**Status**: ✅ Complete
---
## What Was Fixed
All import statements have been updated to use the new `src/` package structure.
### Files Updated (13 files)
#### Core Module Imports
1. **`src/core/database.py`**
- `from config import``from src.core.config import`
2. **`src/core/face_processing.py`**
- `from config import``from src.core.config import`
- `from database import``from src.core.database import`
3. **`src/core/photo_management.py`**
- `from config import``from src.core.config import`
- `from database import``from src.core.database import`
- `from path_utils import``from src.utils.path_utils import`
4. **`src/core/search_stats.py`**
- `from database import``from src.core.database import`
5. **`src/core/tag_management.py`**
- `from config import``from src.core.config import`
- `from database import``from src.core.database import`
#### GUI Module Imports
6. **`src/gui/gui_core.py`**
- `from config import``from src.core.config import`
7. **`src/gui/dashboard_gui.py`**
- `from gui_core import``from src.gui.gui_core import`
- `from identify_panel import``from src.gui.identify_panel import`
- `from auto_match_panel import``from src.gui.auto_match_panel import`
- `from modify_panel import``from src.gui.modify_panel import`
- `from tag_manager_panel import``from src.gui.tag_manager_panel import`
- `from search_stats import``from src.core.search_stats import`
- `from database import``from src.core.database import`
- `from tag_management import``from src.core.tag_management import`
- `from face_processing import``from src.core.face_processing import`
8. **`src/gui/identify_panel.py`**
- `from config import``from src.core.config import`
- `from database import``from src.core.database import`
- `from face_processing import``from src.core.face_processing import`
- `from gui_core import``from src.gui.gui_core import`
9. **`src/gui/auto_match_panel.py`**
- `from config import``from src.core.config import`
- `from database import``from src.core.database import`
- `from face_processing import``from src.core.face_processing import`
- `from gui_core import``from src.gui.gui_core import`
10. **`src/gui/modify_panel.py`**
- `from config import``from src.core.config import`
- `from database import``from src.core.database import`
- `from face_processing import``from src.core.face_processing import`
- `from gui_core import``from src.gui.gui_core import`
11. **`src/gui/tag_manager_panel.py`**
- `from database import``from src.core.database import`
- `from gui_core import``from src.gui.gui_core import`
- `from tag_management import``from src.core.tag_management import`
- `from face_processing import``from src.core.face_processing import`
#### Entry Point
12. **`src/photo_tagger.py`**
- `from config import``from src.core.config import`
- `from database import``from src.core.database import`
- `from face_processing import``from src.core.face_processing import`
- `from photo_management import``from src.core.photo_management import`
- `from tag_management import``from src.core.tag_management import`
- `from search_stats import``from src.core.search_stats import`
- `from gui_core import``from src.gui.gui_core import`
- `from dashboard_gui import``from src.gui.dashboard_gui import`
- Removed imports for archived GUI files
#### Launcher Created
13. **`run_dashboard.py`** (NEW)
- Created launcher script that adds project root to Python path
- Initializes all required dependencies (DatabaseManager, FaceProcessor, etc.)
- Properly instantiates and runs DashboardGUI
---
## Running the Application
### Method 1: Using Launcher (Recommended)
```bash
# Activate virtual environment
source venv/bin/activate
# Run dashboard
python run_dashboard.py
```
### Method 2: Using Python Module
```bash
# Activate virtual environment
source venv/bin/activate
# Run as module
python -m src.gui.dashboard_gui
```
### Method 3: CLI Tool
```bash
# Activate virtual environment
source venv/bin/activate
# Run CLI
python -m src.photo_tagger --help
```
---
## Import Pattern Reference
### Core Modules
```python
from src.core.config import DEFAULT_DB_PATH, ...
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
from src.core.photo_management import PhotoManager
from src.core.tag_management import TagManager
from src.core.search_stats import SearchStats
```
### GUI Modules
```python
from src.gui.gui_core import GUICore
from src.gui.dashboard_gui import DashboardGUI
from src.gui.identify_panel import IdentifyPanel
from src.gui.auto_match_panel import AutoMatchPanel
from src.gui.modify_panel import ModifyPanel
from src.gui.tag_manager_panel import TagManagerPanel
```
### Utility Modules
```python
from src.utils.path_utils import normalize_path, validate_path_exists
```
---
## Verification Steps
### ✅ Completed
- [x] All core module imports updated
- [x] All GUI module imports updated
- [x] Entry point (photo_tagger.py) updated
- [x] Launcher script created
- [x] Dashboard tested and running
### 🔄 To Do
- [ ] Update test files (tests/*.py)
- [ ] Update demo scripts (demo.sh, run_deepface_gui.sh)
- [ ] Run full test suite
- [ ] Verify all panels work correctly
- [ ] Commit changes to git
---
## Known Issues & Solutions
### Issue: ModuleNotFoundError for 'src'
**Solution**: Use the launcher script `run_dashboard.py` which adds project root to path
### Issue: ImportError for PIL.ImageTk
**Solution**: Make sure to use the virtual environment:
```bash
source venv/bin/activate
pip install Pillow
```
### Issue: Relative imports not working
**Solution**: All imports now use absolute imports from `src.`
---
## File Structure After Fix
```
src/
├── core/ # All core imports work ✅
├── gui/ # All GUI imports work ✅
└── utils/ # Utils imports work ✅
Project Root:
├── run_dashboard.py # Launcher script ✅
└── src/ # Package with proper imports ✅
```
---
## Next Steps
1. **Test All Functionality**
```bash
source venv/bin/activate
python run_dashboard.py
```
2. **Update Test Files**
- Fix imports in `tests/*.py`
- Run test suite
3. **Update Scripts**
- Update `demo.sh`
- Update `run_deepface_gui.sh`
4. **Commit Changes**
```bash
git add .
git commit -m "fix: update all import statements for new structure"
git push
```
---
**Status**: Import statements fixed ✅ | Application running ✅ | Tests pending ⏳

File diff suppressed because it is too large Load Diff

View File

@ -1,126 +0,0 @@
# Monorepo Migration Summary
This document summarizes the migration from separate `punimtag` and `punimtag-viewer` projects to a unified monorepo structure.
## Migration Date
December 2024
## Changes Made
### Directory Structure
**Before:**
```
punimtag/
├── src/web/ # Backend API
└── frontend/ # Admin React frontend
punimtag-viewer/ # Separate repository
└── (Next.js viewer)
```
**After:**
```
punimtag/
├── backend/ # FastAPI backend (renamed from src/web)
├── admin-frontend/ # React admin interface (renamed from frontend)
└── viewer-frontend/ # Next.js viewer (moved from punimtag-viewer)
```
### Import Path Changes
All Python imports have been updated:
- `from src.web.*``from backend.*`
- `import src.web.*``import backend.*`
### Configuration Updates
1. **install.sh**: Updated to install dependencies for both frontends
2. **package.json**: Created root package.json with workspace scripts
3. **run_api_with_worker.sh**: Updated to use `backend.app` instead of `src.web.app`
4. **run_worker.sh**: Updated to use `backend.worker` instead of `src.web.worker`
5. **docker-compose.yml**: Updated service commands to use `backend.*` paths
### Environment Files
- **admin-frontend/.env**: Backend API URL configuration
- **viewer-frontend/.env.local**: Database and NextAuth configuration
### Port Configuration
- **Admin Frontend**: Port 3000 (unchanged)
- **Viewer Frontend**: Port 3001 (configured in viewer-frontend/package.json)
- **Backend API**: Port 8000 (unchanged)
## Running the Application
### Development
**Terminal 1 - Backend:**
```bash
source venv/bin/activate
export PYTHONPATH=$(pwd)
uvicorn backend.app:app --host 127.0.0.1 --port 8000
```
**Terminal 2 - Admin Frontend:**
```bash
cd admin-frontend
npm run dev
```
**Terminal 3 - Viewer Frontend:**
```bash
cd viewer-frontend
npm run dev
```
### Using Root Scripts
```bash
# Install all dependencies
npm run install:all
# Run individual services
npm run dev:backend
npm run dev:admin
npm run dev:viewer
```
## Benefits
1. **Unified Setup**: Single installation script for all components
2. **Easier Maintenance**: All code in one repository
3. **Shared Configuration**: Common environment variables and settings
4. **Simplified Deployment**: Single repository to deploy
5. **Better Organization**: Clear separation of admin and viewer interfaces
## Migration Checklist
- [x] Rename `src/web` to `backend`
- [x] Rename `frontend` to `admin-frontend`
- [x] Copy `punimtag-viewer` to `viewer-frontend`
- [x] Update all Python imports
- [x] Update all scripts
- [x] Update install.sh
- [x] Create root package.json
- [x] Update docker-compose.yml
- [x] Update README.md
- [x] Update scripts in scripts/ directory
## Notes
- The viewer frontend manages the `punimtag_auth` database
- Both frontends share the main `punimtag` database
- Backend API serves both frontends
- All database schemas remain unchanged
## Next Steps
1. Test all three services start correctly
2. Verify database connections work
3. Test authentication flows
4. Update CI/CD pipelines if applicable
5. Archive or remove the old `punimtag-viewer` repository

Some files were not shown because too many files have changed in this diff Show More