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.
This commit is contained in:
tanyar09 2025-11-11 13:45:43 -05:00
parent 85dd6a68b3
commit 20f1a4207f
9 changed files with 138 additions and 37 deletions

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -18,7 +18,8 @@ export default function Identify() {
const [sortDir, setSortDir] = useState<SortDir>('desc')
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [dateProcessed, setDateProcessed] = useState<string>('')
// dateProcessed filter is hidden, so state removed
// const [dateProcessed, setDateProcessed] = useState<string>('')
const [currentIdx, setCurrentIdx] = useState(0)
const currentFace = faces[currentIdx]
@ -28,7 +29,7 @@ export default function Identify() {
const [selectedSimilar, setSelectedSimilar] = useState<Record<number, boolean>>({})
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<Person[]>([])
@ -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() {
<option value="asc">Asc</option>
</select>
</div>
<div>
{/* Date Processed filter hidden for now */}
{/* <div>
<label className="block text-sm font-medium text-gray-700">Date Processed On</label>
<input type="date" value={dateProcessed} onChange={(e) => setDateProcessed(e.target.value)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
</div> */}
<div>
<label className="block text-sm font-medium text-gray-700">Min Quality</label>
<input type="range" min={0} max={1} step={0.05} value={minQuality}

View File

@ -3,7 +3,7 @@ import { photosApi, PhotoSearchResult } from '../api/photos'
import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags'
import { apiClient } from '../api/client'
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags'
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed'
const SEARCH_TYPES: { value: SearchType; label: string }[] = [
{ value: 'name', label: 'Search photos by name' },
@ -11,6 +11,8 @@ const SEARCH_TYPES: { value: SearchType; label: string }[] = [
{ value: 'tags', label: 'Search photos by tags' },
{ value: 'no_faces', label: 'Photos without faces' },
{ value: 'no_tags', label: 'Photos without tags' },
{ value: 'processed', label: 'Search processed photos' },
{ value: 'unprocessed', label: 'Search un-processed photos' },
]
type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken'
@ -124,7 +126,7 @@ export default function Search() {
}
useEffect(() => {
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() {
</div>
{/* Search Inputs */}
{(searchType !== 'no_faces' && searchType !== 'no_tags') && (
{(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && (
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2">
{searchType === 'name' && (
<div className="flex items-center gap-2">

View File

@ -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,

View File

@ -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,

View File

@ -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 []

View File

@ -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