From 20f1a4207fcb901f6ac1a950b601e8b3888d5b94 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Tue, 11 Nov 2025 13:45:43 -0500 Subject: [PATCH] feat: Add processed and unprocessed photo search options and sessionStorage management This commit introduces new search options for processed and unprocessed photos in the Search component, enhancing the photo management capabilities. The Identify component has been updated to clear sessionStorage settings on logout and authentication failure, improving user experience by ensuring a clean state. Additionally, the API has been modified to support these new search parameters, ensuring seamless integration with the frontend. Documentation has been updated to reflect these changes. --- frontend/src/api/client.ts | 2 ++ frontend/src/api/photos.ts | 2 +- frontend/src/context/AuthContext.tsx | 2 ++ frontend/src/pages/Identify.tsx | 33 ++++++++++-------- frontend/src/pages/Search.tsx | 8 +++-- src/web/api/faces.py | 7 +++- src/web/api/photos.py | 50 +++++++++++++++++++++++++- src/web/services/face_service.py | 19 ++-------- src/web/services/search_service.py | 52 ++++++++++++++++++++++++++++ 9 files changed, 138 insertions(+), 37 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fe6486e..bbf8d09 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -25,6 +25,8 @@ apiClient.interceptors.response.use( if (error.response?.status === 401) { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') + // Clear sessionStorage settings on authentication failure + sessionStorage.removeItem('identify_settings') window.location.href = '/login' } return Promise.reject(error) diff --git a/frontend/src/api/photos.ts b/frontend/src/api/photos.ts index 9346531..f3143c0 100644 --- a/frontend/src/api/photos.ts +++ b/frontend/src/api/photos.ts @@ -73,7 +73,7 @@ export const photosApi = { }, searchPhotos: async (params: { - search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' + search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' person_name?: string tag_names?: string match_all?: boolean diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 3e9a647..9ad80c5 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -74,6 +74,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = () => { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') + // Clear sessionStorage settings on logout + sessionStorage.removeItem('identify_settings') setAuthState({ isAuthenticated: false, username: null, diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index cc6144a..f88c868 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -18,7 +18,8 @@ export default function Identify() { const [sortDir, setSortDir] = useState('desc') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') - const [dateProcessed, setDateProcessed] = useState('') + // dateProcessed filter is hidden, so state removed + // const [dateProcessed, setDateProcessed] = useState('') const [currentIdx, setCurrentIdx] = useState(0) const currentFace = faces[currentIdx] @@ -28,7 +29,7 @@ export default function Identify() { const [selectedSimilar, setSelectedSimilar] = useState>({}) const [uniqueFacesOnly, setUniqueFacesOnly] = useState(true) - // LocalStorage key for persisting settings + // SessionStorage key for persisting settings (clears when tab/window closes) const SETTINGS_KEY = 'identify_settings' const [people, setPeople] = useState([]) @@ -77,7 +78,8 @@ export default function Identify() { min_quality: minQuality, date_taken_from: dateFrom || undefined, date_taken_to: dateTo || undefined, - date_processed: dateProcessed || undefined, + // date_processed filter is hidden, so don't send it + // date_processed: dateProcessed || undefined, sort_by: sortBy, sort_dir: sortDir, tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined, @@ -238,10 +240,10 @@ export default function Identify() { } } - // Load settings from localStorage on mount + // Load settings from sessionStorage on mount useEffect(() => { try { - const saved = localStorage.getItem(SETTINGS_KEY) + const saved = sessionStorage.getItem(SETTINGS_KEY) if (saved) { const settings = JSON.parse(saved) if (settings.pageSize !== undefined) setPageSize(settings.pageSize) @@ -250,20 +252,21 @@ export default function Identify() { if (settings.sortDir !== undefined) setSortDir(settings.sortDir) if (settings.dateFrom !== undefined) setDateFrom(settings.dateFrom) if (settings.dateTo !== undefined) setDateTo(settings.dateTo) - if (settings.dateProcessed !== undefined) setDateProcessed(settings.dateProcessed) + // dateProcessed filter is hidden, so don't load it from localStorage + // if (settings.dateProcessed !== undefined) setDateProcessed(settings.dateProcessed) if (settings.uniqueFacesOnly !== undefined) setUniqueFacesOnly(settings.uniqueFacesOnly) if (settings.compareEnabled !== undefined) setCompareEnabled(settings.compareEnabled) if (settings.selectedTags !== undefined) setSelectedTags(settings.selectedTags) } } catch (error) { - console.error('Error loading settings from localStorage:', error) + console.error('Error loading settings from sessionStorage:', error) } finally { setSettingsLoaded(true) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Save settings to localStorage whenever they change (but only after initial load) + // Save settings to sessionStorage whenever they change (but only after initial load) useEffect(() => { if (!settingsLoaded) return // Don't save during initial load try { @@ -274,16 +277,17 @@ export default function Identify() { sortDir, dateFrom, dateTo, - dateProcessed, + // dateProcessed filter is hidden, so don't save it + // dateProcessed, uniqueFacesOnly, compareEnabled, selectedTags, } - localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) + sessionStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) } catch (error) { - console.error('Error saving settings to localStorage:', error) + console.error('Error saving settings to sessionStorage:', error) } - }, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, dateProcessed, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded]) + }, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded]) // Initial load on mount (after settings are loaded) useEffect(() => { @@ -551,11 +555,12 @@ export default function Identify() { -
+ {/* Date Processed filter hidden for now */} + {/*
setDateProcessed(e.target.value)} className="mt-1 block w-full border rounded px-2 py-1" /> -
+
*/}
{ - if (searchType === 'no_faces' || searchType === 'no_tags') { + if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed') { handleSearch() } // Clear selected tags when switching away from tag search @@ -461,7 +463,7 @@ export default function Search() {
{/* Search Inputs */} - {(searchType !== 'no_faces' && searchType !== 'no_tags') && ( + {(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && (
{searchType === 'name' && (
diff --git a/src/web/api/faces.py b/src/web/api/faces.py index 5ae40d5..fc4c563 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -134,6 +134,10 @@ def get_unidentified_faces( if tag_names: tag_names_list = [t.strip() for t in tag_names.split(',') if t.strip()] + # Convert single date_processed to date_processed_from and date_processed_to (exact date match) + date_processed_from = dp + date_processed_to = dp + faces, total = list_unidentified_faces( db, page=page, @@ -141,7 +145,8 @@ def get_unidentified_faces( min_quality=min_quality, date_taken_from=dtf, date_taken_to=dtt, - date_processed=dp, + date_processed_from=date_processed_from, + date_processed_to=date_processed_to, sort_by=sort_by, sort_dir=sort_dir, tag_names=tag_names_list, diff --git a/src/web/api/photos.py b/src/web/api/photos.py index 716e862..e87c7b3 100644 --- a/src/web/api/photos.py +++ b/src/web/api/photos.py @@ -35,6 +35,8 @@ from src.web.services.search_service import ( get_photo_tags, get_photos_without_faces, get_photos_without_tags, + get_processed_photos, + get_unprocessed_photos, search_photos_by_date, search_photos_by_name, search_photos_by_tags, @@ -46,7 +48,7 @@ router = APIRouter(prefix="/photos", tags=["photos"]) @router.get("", response_model=SearchPhotosResponse) def search_photos( - search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags"), + search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags, processed, unprocessed"), person_name: Optional[str] = Query(None, description="Person name for name search"), tag_names: Optional[str] = Query(None, description="Comma-separated tag names for tag search"), match_all: bool = Query(False, description="Match all tags (for tag search)"), @@ -65,6 +67,8 @@ def search_photos( - Search by tags: tag_names required (comma-separated) - Search no faces: returns photos without faces - Search no tags: returns photos without tags + - Search processed: returns photos that have been processed for face detection + - Search unprocessed: returns photos that have not been processed for face detection """ items: List[PhotoSearchResult] = [] total = 0 @@ -202,6 +206,50 @@ def search_photos( face_count=face_count, ) ) + elif search_type == "processed": + results, total = get_processed_photos(db, folder_path, page, page_size) + for photo in results: + tags = get_photo_tags(db, photo.id) + face_count = get_photo_face_count(db, photo.id) + person_name_val = get_photo_person(db, photo.id) + # Convert datetime to date for date_added + date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added + items.append( + PhotoSearchResult( + id=photo.id, + path=photo.path, + filename=photo.filename, + date_taken=photo.date_taken, + date_added=date_added, + processed=photo.processed, + person_name=person_name_val, + tags=tags, + has_faces=face_count > 0, + face_count=face_count, + ) + ) + elif search_type == "unprocessed": + results, total = get_unprocessed_photos(db, folder_path, page, page_size) + for photo in results: + tags = get_photo_tags(db, photo.id) + face_count = get_photo_face_count(db, photo.id) + person_name_val = get_photo_person(db, photo.id) + # Convert datetime to date for date_added + date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added + items.append( + PhotoSearchResult( + id=photo.id, + path=photo.path, + filename=photo.filename, + date_taken=photo.date_taken, + date_added=date_added, + processed=photo.processed, + person_name=person_name_val, + tags=tags, + has_faces=face_count > 0, + face_count=face_count, + ) + ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index 5cbd409..bacb095 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -1200,7 +1200,6 @@ def list_unidentified_faces( date_to: Optional[date] = None, date_taken_from: Optional[date] = None, date_taken_to: Optional[date] = None, - date_processed: Optional[date] = None, date_processed_from: Optional[date] = None, date_processed_to: Optional[date] = None, sort_by: str = "quality", @@ -1264,9 +1263,6 @@ def list_unidentified_faces( query = query.filter(Photo.date_taken <= date_taken_to) # Date processed filters (uses photo.date_added) - if date_processed is not None: - # Filter by exact date processed - query = query.filter(func.date(Photo.date_added) == date_processed) if date_processed_from is not None: query = query.filter(func.date(Photo.date_added) >= date_processed_from) if date_processed_to is not None: @@ -1732,8 +1728,7 @@ def find_auto_match_matches( Args: tolerance: Similarity tolerance (default: 0.6) - filter_frontal_only: Only include persons with frontal or tilted reference face (not profile). - When True (auto-accept mode), also requires reference faces to have quality > 0.5 + filter_frontal_only: Only include persons with frontal or tilted reference face (not profile) Returns: List of (person_id, reference_face_id, reference_face, matches) tuples @@ -1752,25 +1747,15 @@ def find_auto_match_matches( # JOIN photos p ON f.photo_id = p.id # WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 # ORDER BY f.person_id, f.quality_score DESC - # - # For auto-accept mode (filter_frontal_only=True), also require quality > 0.5 - quality_threshold = 0.3 identified_faces: List[Face] = ( db.query(Face) .join(Photo, Face.photo_id == Photo.id) .filter(Face.person_id.isnot(None)) - .filter(Face.quality_score >= quality_threshold) + .filter(Face.quality_score >= 0.3) .order_by(Face.person_id, Face.quality_score.desc()) .all() ) - # For auto-accept mode, filter out reference faces with quality <= 0.5 - if filter_frontal_only: - identified_faces = [ - f for f in identified_faces - if f.quality_score is not None and float(f.quality_score) > 0.5 - ] - if not identified_faces: return [] diff --git a/src/web/services/search_service.py b/src/web/services/search_service.py index d47b6f0..abbadcd 100644 --- a/src/web/services/search_service.py +++ b/src/web/services/search_service.py @@ -264,3 +264,55 @@ def get_photo_face_count(db: Session, photo_id: int) -> int: """Get face count for a photo.""" return db.query(Face).filter(Face.photo_id == photo_id).count() + +def get_processed_photos( + db: Session, + folder_path: Optional[str] = None, + page: int = 1, + page_size: int = 50, +) -> Tuple[List[Photo], int]: + """Get photos that have been processed for face detection. + + Matches desktop behavior exactly. + """ + query = db.query(Photo).filter(Photo.processed == True) # noqa: E712 + + # Apply folder filter if provided + if folder_path: + folder_path = folder_path.strip() + query = query.filter(Photo.path.startswith(folder_path)) + + # Total count + total = query.count() + + # Pagination and sorting + results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all() + + return results, total + + +def get_unprocessed_photos( + db: Session, + folder_path: Optional[str] = None, + page: int = 1, + page_size: int = 50, +) -> Tuple[List[Photo], int]: + """Get photos that have not been processed for face detection. + + Matches desktop behavior exactly. + """ + query = db.query(Photo).filter(Photo.processed == False) # noqa: E712 + + # Apply folder filter if provided + if folder_path: + folder_path = folder_path.strip() + query = query.filter(Photo.path.startswith(folder_path)) + + # Total count + total = query.count() + + # Pagination and sorting + results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all() + + return results, total +