diff --git a/README.md b/README.md index 119c81f..ec2fe7a 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,9 @@ The web version uses the **exact same schema** as the desktop version for full c redis-server ``` -**Terminal 2 - Backend API (automatically starts RQ worker):** +#### Option 1: Manual Start (Recommended for Development) + +**Terminal 1 - Backend API:** ```bash cd /home/ladmin/Code/punimtag source venv/bin/activate @@ -126,33 +128,99 @@ uvicorn src.web.app:app --host 127.0.0.1 --port 8000 You should see: ``` +✅ Database already initialized (7 tables exist) ✅ RQ worker started in background subprocess (PID: ...) INFO: Started server process INFO: Uvicorn running on http://127.0.0.1:8000 ``` -**Note:** The RQ worker automatically starts in a background subprocess when the API starts. You'll see a confirmation message with the worker PID. If Redis isn't running, you'll see a warning message. - -**Terminal 3 - Frontend:** +**Terminal 2 - Frontend:** ```bash cd /home/ladmin/Code/punimtag/frontend npm run dev ``` -Then open your browser to **http://localhost:3000** +You should see: +``` +VITE v5.4.21 ready in 811 ms +➜ Local: http://localhost:3000/ +``` -**Default Login:** -- Username: `admin` -- Password: `admin` +#### Option 2: Using Helper Script (Backend + Worker) + +**Terminal 1 - Backend API + Worker:** +```bash +cd /home/ladmin/Code/punimtag +./run_api_with_worker.sh +``` + +This script will: +- Check if Redis is running (start it if needed) +- Start the RQ worker in the background +- Start the FastAPI server +- Handle cleanup on Ctrl+C + +**Terminal 2 - Frontend:** +```bash +cd /home/ladmin/Code/punimtag/frontend +npm run dev +``` + +#### Access the Application + +1. Open your browser to **http://localhost:3000** +2. Login with default credentials: + - Username: `admin` + - Password: `admin` +3. API documentation available at **http://127.0.0.1:8000/docs** + +#### Troubleshooting + +**Port 8000 already in use:** +```bash +# Find and kill the process using port 8000 +lsof -i :8000 +kill + +# Or use pkill +pkill -f "uvicorn.*app" +``` + +**Port 3000 already in use:** +```bash +# Find and kill the process using port 3000 +lsof -i :3000 +kill + +# Or change the port in frontend/vite.config.ts +``` + +**Redis not running:** +```bash +# Start Redis +sudo systemctl start redis-server +# Or +redis-server +``` + +**Database issues:** +```bash +# Recreate all tables (WARNING: This will delete all data!) +cd /home/ladmin/Code/punimtag +source venv/bin/activate +export PYTHONPATH=/home/ladmin/Code/punimtag +python scripts/recreate_tables_web.py +``` + +#### Important Notes -**Note:** - The database and tables are **automatically created on first startup** - no manual setup needed! - The RQ worker starts automatically in a background subprocess when the API server starts - Make sure Redis is running first, or the worker won't start - Worker names are unique to avoid conflicts when restarting - Photo uploads are stored in `data/uploads` (configurable via `PHOTO_STORAGE_DIR` env var) -- **DeepFace models download automatically on first use** (can take 5-10 minutes) -- **If port 8000 is in use**, kill the process: `lsof -i :8000` then `kill ` or `pkill -f "uvicorn.*app"` +- **DeepFace models download automatically on first use** (can take 5-10 minutes, ~100MB) +- First run is slower due to model downloads (subsequent runs are faster) --- diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index 197d7e0..d89bef0 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -184,6 +184,7 @@ export const facesApi = { sort_dir?: 'asc' | 'desc' tag_names?: string match_all?: boolean + photo_ids?: string }): Promise => { const response = await apiClient.get('/api/v1/faces/unidentified', { params, diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts index 7f19df7..4b6e105 100644 --- a/frontend/src/api/tags.ts +++ b/frontend/src/api/tags.ts @@ -52,6 +52,7 @@ export interface PhotoWithTagsItem { date_taken?: string | null date_added?: string | null face_count: number + unidentified_face_count: number // Count of faces with person_id IS NULL tags: string // Comma-separated tags string } diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 6483e2d..653f2a2 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, useRef } from 'react' +import { useSearchParams } from 'react-router-dom' import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces' import peopleApi, { Person } from '../api/people' import { apiClient } from '../api/client' @@ -10,6 +11,7 @@ type SortDir = 'asc' | 'desc' export default function Identify() { const { isDeveloperMode } = useDeveloperMode() + const [searchParams, setSearchParams] = useSearchParams() const [faces, setFaces] = useState([]) const [, setTotal] = useState(0) const [pageSize, setPageSize] = useState(50) @@ -20,6 +22,14 @@ export default function Identify() { const [dateTo, setDateTo] = useState('') // dateProcessed filter is hidden, so state removed // const [dateProcessed, setDateProcessed] = useState('') + + // Parse photo_ids from URL parameter + const photoIds = useMemo(() => { + const photoIdsParam = searchParams.get('photo_ids') + if (!photoIdsParam) return null + const ids = photoIdsParam.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id)) + return ids.length > 0 ? ids : null + }, [searchParams]) const [currentIdx, setCurrentIdx] = useState(0) const currentFace = faces[currentIdx] @@ -48,6 +58,7 @@ export default function Identify() { const [availableTags, setAvailableTags] = useState([]) const [selectedTags, setSelectedTags] = useState([]) const [tagsExpanded, setTagsExpanded] = useState(false) + const [selectKey, setSelectKey] = useState(0) // Key to force select re-render when clearing // Store form data per face ID (matching desktop behavior) const [faceFormData, setFaceFormData] = useState { + const loadFaces = async (clearState: boolean = false, ignorePhotoIds: boolean = false) => { setLoadingFaces(true) try { @@ -95,6 +106,7 @@ export default function Identify() { sort_dir: sortDir, tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined, match_all: false, // Default to match any tag + photo_ids: (ignorePhotoIds || !photoIds) ? undefined : photoIds.join(','), // Filter by photo IDs if provided and not ignored }) // Apply unique faces filter if enabled @@ -258,8 +270,15 @@ export default function Identify() { } // Load settings from sessionStorage on mount + // If photoIds is provided, skip loading filter settings (we want to show all faces from those photos) useEffect(() => { try { + // If photoIds is in URL, don't load filter settings - we'll use defaults to show all faces + if (photoIds !== null) { + setSettingsLoaded(true) + return + } + const saved = sessionStorage.getItem(SETTINGS_KEY) if (saved) { const settings = JSON.parse(saved) @@ -281,11 +300,18 @@ export default function Identify() { setSettingsLoaded(true) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [photoIds]) // Load state from sessionStorage on mount (faces, current index, similar, form data) + // Skip state restoration if photoIds is provided in URL (we want fresh filtered data) useEffect(() => { try { + // If photoIds is in URL, don't restore state - we need to load fresh filtered faces + if (photoIds !== null) { + setStateRestored(true) + return + } + const saved = sessionStorage.getItem(STATE_KEY) if (saved) { const state = JSON.parse(saved) @@ -318,7 +344,7 @@ export default function Identify() { setStateRestored(true) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [photoIds]) // Save state to sessionStorage whenever it changes (but only after initial restore) useEffect(() => { @@ -395,12 +421,36 @@ export default function Identify() { } }, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded]) + // Load tags and people on mount (always, regardless of other conditions) + useEffect(() => { + if (settingsLoaded) { + 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) + useEffect(() => { + if (photoIds !== null && settingsLoaded) { + // Reset filters to defaults to show all faces from the selected photos + setMinQuality(0.0) + setDateFrom('') + setDateTo('') + setSelectedTags([]) + // 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) useEffect(() => { if (!initialLoadRef.current && settingsLoaded && stateRestored) { initialLoadRef.current = true - // Only load if we didn't restore state (no faces means we need to load) - if (faces.length === 0) { + // Always load if photoIds is provided (from URL) - we need fresh filtered faces + // Otherwise, only load if we didn't restore state (no faces means we need to load) + if (photoIds !== null || faces.length === 0) { loadFaces() // If we're loading fresh, mark restoration as complete immediately restorationCompleteRef.current = true @@ -413,11 +463,9 @@ export default function Identify() { }, 50) } } - loadPeople() - loadTags() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settingsLoaded, stateRestored]) + }, [settingsLoaded, stateRestored, photoIds]) // Reload when uniqueFacesOnly changes (immediate reload) // But only if restoration is complete (prevents reload during initial restoration) @@ -589,7 +637,14 @@ export default function Identify() { return (
-

Identify

+

+ Identify + {photoIds && ( + + (Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''}) + + )} +

{/* Left: Controls and current face */} @@ -695,6 +750,7 @@ export default function Identify() {
-
@@ -756,7 +807,18 @@ export default function Identify() { - - -
+
+ +
+ +
- )} +
+ + @@ -522,13 +557,26 @@ export default function Tags() { {photo.path} {photo.processed ? 'Yes' : 'No'} {photo.date_taken || 'Unknown'} - {photo.face_count} + + {photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? ( + + ) : ( + {photo.face_count || 0} + )} +
{getPhotoTags(photo.id)} @@ -545,15 +593,138 @@ export default function Tags() { {viewMode === 'icons' && (
-

Icons view coming soon...

+ {folderGroups.map(folder => ( +
+ {/* Folder Header */} +
+
+ + + 📁 {folder.folderName} ({folder.photoCount} photos) + + +
+
+ + {/* Photo Grid */} + {folderStates[folder.folderPath] === true && ( +
+ {folder.photos.map(photo => { + const photoUrl = `/api/v1/photos/${photo.id}/image` + const isSelected = selectedPhotoIds.has(photo.id) + + return ( +
+ {/* Checkbox */} +
+ { + const newSet = new Set(selectedPhotoIds) + if (e.target.checked) { + newSet.add(photo.id) + } else { + newSet.delete(photo.id) + } + setSelectedPhotoIds(newSet) + }} + className="w-5 h-5 cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* Action Buttons */} +
+ {photo.processed && (photo.unidentified_face_count ?? 0) > 0 && ( + + )} + +
+ + {/* Thumbnail */} +
+ {photo.filename} window.open(photoUrl, '_blank')} + 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' + }} + /> +
+ + {/* Metadata Overlay */} +
+
+
+ ID: {photo.id} +
+
+ {photo.filename} +
+
+ + {photo.processed ? '✓ Processed' : '○ Unprocessed'} + +
+ {photo.date_taken && ( +
+ 📅 {photo.date_taken} +
+ )} +
+ 👤 {photo.face_count} face{photo.face_count !== 1 ? 's' : ''} +
+
+ 🏷️ {getPhotoTags(photo.id)} +
+
+
+
+ ) + })} +
+ )} +
+ ))}
)} - {viewMode === 'compact' && ( -
-

Compact view coming soon...

-
- )}
{/* Manage Tags Dialog */} diff --git a/src/web/api/faces.py b/src/web/api/faces.py index 4f40842..c9be616 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -113,6 +113,7 @@ def get_unidentified_faces( sort_dir: str = Query("desc"), tag_names: str | None = Query(None, description="Comma-separated tag names for filtering"), match_all: bool = Query(False, description="Match all tags (for tag filtering)"), + photo_ids: str | None = Query(None, description="Comma-separated photo IDs for filtering"), db: Session = Depends(get_db), ) -> UnidentifiedFacesResponse: """Get unidentified faces with filters and pagination.""" @@ -138,6 +139,14 @@ def get_unidentified_faces( if tag_names: tag_names_list = [t.strip() for t in tag_names.split(',') if t.strip()] + # Parse photo IDs + photo_ids_list = None + if photo_ids: + try: + photo_ids_list = [int(pid.strip()) for pid in photo_ids.split(',') if pid.strip()] + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid photo_ids format") + # Convert single date_processed to date_processed_from and date_processed_to (exact date match) date_processed_from = dp date_processed_to = dp @@ -155,6 +164,7 @@ def get_unidentified_faces( sort_dir=sort_dir, tag_names=tag_names_list, match_all=match_all, + photo_ids=photo_ids_list, ) items = [ diff --git a/src/web/api/tags.py b/src/web/api/tags.py index 93df7eb..21a6e5e 100644 --- a/src/web/api/tags.py +++ b/src/web/api/tags.py @@ -180,6 +180,7 @@ def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTa date_taken=p['date_taken'], date_added=p['date_added'], face_count=p['face_count'], + unidentified_face_count=p['unidentified_face_count'], tags=p['tags'], ) for p in photos_data diff --git a/src/web/schemas/tags.py b/src/web/schemas/tags.py index 4bed98d..03354c4 100644 --- a/src/web/schemas/tags.py +++ b/src/web/schemas/tags.py @@ -87,6 +87,7 @@ class PhotoWithTagsItem(BaseModel): date_taken: Optional[str] = None date_added: Optional[str] = None face_count: int + unidentified_face_count: int # Count of faces with person_id IS NULL tags: str # Comma-separated tags string (matching desktop) diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index bacb095..5635d41 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -1206,6 +1206,7 @@ def list_unidentified_faces( sort_dir: str = "desc", tag_names: Optional[List[str]] = None, match_all: bool = False, + photo_ids: Optional[List[int]] = None, ) -> Tuple[List[Face], int]: """Return paginated unidentified faces with filters. @@ -1214,6 +1215,7 @@ def list_unidentified_faces( - Date taken (date_taken_from, date_taken_to) - Date processed (date_processed_from, date_processed_to) - uses photo.date_added - Tags (tag_names, match_all) + - Photo IDs (photo_ids) Legacy parameters (date_from, date_to) are kept for backward compatibility and filter by date_taken when available, else date_added as fallback. @@ -1252,6 +1254,10 @@ def list_unidentified_faces( # No matching tags found - return empty result return [], 0 + # Photo ID filtering + if photo_ids: + query = query.filter(Face.photo_id.in_(photo_ids)) + # Min quality (stored 0.0-1.0) if min_quality is not None: query = query.filter(Face.quality_score >= min_quality) diff --git a/src/web/services/tag_service.py b/src/web/services/tag_service.py index ff6aea4..93050f2 100644 --- a/src/web/services/tag_service.py +++ b/src/web/services/tag_service.py @@ -252,13 +252,21 @@ def get_photos_with_tags(db: Session) -> List[dict]: result = [] for photo in photos: - # Get face count + # Get face count (all faces) face_count = ( db.query(func.count(Face.id)) .filter(Face.photo_id == photo.id) .scalar() or 0 ) + # Get unidentified face count (only faces with person_id IS NULL) + unidentified_face_count = ( + db.query(func.count(Face.id)) + .filter(Face.photo_id == photo.id) + .filter(Face.person_id.is_(None)) + .scalar() or 0 + ) + # Get tags as comma-separated string (matching desktop GROUP_CONCAT) tags_query = ( db.query(Tag.tag_name) @@ -277,6 +285,7 @@ def get_photos_with_tags(db: Session) -> List[dict]: 'date_taken': photo.date_taken.isoformat() if photo.date_taken else None, 'date_added': photo.date_added.isoformat() if photo.date_added else None, 'face_count': face_count, + 'unidentified_face_count': unidentified_face_count, 'tags': tags, })