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:
parent
85dd6a68b3
commit
20f1a4207f
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 []
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user