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 +