diff --git a/data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4 b/data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4 new file mode 100644 index 0000000..3b931f1 Binary files /dev/null and b/data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4 differ diff --git a/frontend/src/api/people.ts b/frontend/src/api/people.ts index 31cd658..2f09ed0 100644 --- a/frontend/src/api/people.ts +++ b/frontend/src/api/people.ts @@ -62,10 +62,17 @@ export const peopleApi = { const res = await apiClient.get(`/api/v1/people/${personId}/faces`) return res.data }, + getVideos: async (personId: number): Promise => { + const res = await apiClient.get(`/api/v1/people/${personId}/videos`) + return res.data + }, acceptMatches: async (personId: number, faceIds: number[]): Promise => { const res = await apiClient.post(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds }) return res.data }, + delete: async (personId: number): Promise => { + await apiClient.delete(`/api/v1/people/${personId}`) + }, } export interface IdentifyFaceResponse { @@ -92,6 +99,21 @@ export interface PersonFacesResponse { total: number } +export interface PersonVideoItem { + id: number + filename: string + path: string + date_taken: string | null + date_added: string + linkage_id: number +} + +export interface PersonVideosResponse { + person_id: number + items: PersonVideoItem[] + total: number +} + export default peopleApi diff --git a/frontend/src/api/videos.ts b/frontend/src/api/videos.ts new file mode 100644 index 0000000..8a05250 --- /dev/null +++ b/frontend/src/api/videos.ts @@ -0,0 +1,122 @@ +import apiClient from './client' + +export interface PersonInfo { + id: number + first_name: string + last_name: string + middle_name?: string | null + maiden_name?: string | null + date_of_birth?: string | null +} + +export interface VideoListItem { + id: number + filename: string + path: string + date_taken: string | null + date_added: string + identified_people: PersonInfo[] + identified_people_count: number +} + +export interface ListVideosResponse { + items: VideoListItem[] + page: number + page_size: number + total: number +} + +export interface VideoPersonInfo { + person_id: number + first_name: string + last_name: string + middle_name?: string | null + maiden_name?: string | null + date_of_birth?: string | null + identified_by: string | null + identified_date: string +} + +export interface VideoPeopleResponse { + video_id: number + people: VideoPersonInfo[] +} + +export interface IdentifyVideoRequest { + person_id?: number + first_name?: string + last_name?: string + middle_name?: string + maiden_name?: string + date_of_birth?: string | null +} + +export interface IdentifyVideoResponse { + video_id: number + person_id: number + created_person: boolean + message: string +} + +export interface RemoveVideoPersonResponse { + video_id: number + person_id: number + removed: boolean + message: string +} + +export const videosApi = { + listVideos: async (params: { + page?: number + page_size?: number + folder_path?: string + date_from?: string + date_to?: string + has_people?: boolean + person_name?: string + sort_by?: string + sort_dir?: string + }): Promise => { + const res = await apiClient.get('/api/v1/videos', { params }) + return res.data + }, + + getVideoPeople: async (videoId: number): Promise => { + const res = await apiClient.get(`/api/v1/videos/${videoId}/people`) + return res.data + }, + + identifyPerson: async ( + videoId: number, + request: IdentifyVideoRequest + ): Promise => { + const res = await apiClient.post( + `/api/v1/videos/${videoId}/identify`, + request + ) + return res.data + }, + + removePerson: async ( + videoId: number, + personId: number + ): Promise => { + const res = await apiClient.delete( + `/api/v1/videos/${videoId}/people/${personId}` + ) + return res.data + }, + + getThumbnailUrl: (videoId: number): string => { + const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000' + return `${baseURL}/api/v1/videos/${videoId}/thumbnail` + }, + + getVideoUrl: (videoId: number): string => { + const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000' + return `${baseURL}/api/v1/videos/${videoId}/video` + }, +} + +export default videosApi + diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 9dc35d9..7aa3c36 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -4,6 +4,7 @@ import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces' import peopleApi, { Person } from '../api/people' import { apiClient } from '../api/client' import tagsApi, { TagResponse } from '../api/tags' +import videosApi, { VideoListItem, VideoPersonInfo, IdentifyVideoRequest } from '../api/videos' import { useDeveloperMode } from '../context/DeveloperModeContext' import { useAuth } from '../context/AuthContext' import pendingIdentificationsApi, { @@ -76,6 +77,33 @@ export default function Identify() { // Tab state const [activeTab, setActiveTab] = useState<'faces' | 'videos'>('faces') + // Video identification state + const [videos, setVideos] = useState([]) + const [videosTotal, setVideosTotal] = useState(0) + const [videosPage, setVideosPage] = useState(1) + const [videosPageSize, setVideosPageSize] = useState(50) + const [videosLoading, setVideosLoading] = useState(false) + const [selectedVideo, setSelectedVideo] = useState(null) + const [videoPeople, setVideoPeople] = useState([]) + const [videoPeopleLoading, setVideoPeopleLoading] = useState(false) + const [videosFolderFilter, setVideosFolderFilter] = useState('') + const [videosDateFrom, setVideosDateFrom] = useState('') + const [videosDateTo, setVideosDateTo] = useState('') + const [videosHasPeople, setVideosHasPeople] = useState(undefined) + const [videosPersonName, setVideosPersonName] = useState('') + const [videosSortBy, setVideosSortBy] = useState('filename') + const [videosSortDir, setVideosSortDir] = useState('asc') + const [videosFiltersCollapsed, setVideosFiltersCollapsed] = useState(true) + + // Person identification form state + const [videoPersonId, setVideoPersonId] = useState(undefined) + const [videoFirstName, setVideoFirstName] = useState('') + const [videoLastName, setVideoLastName] = useState('') + const [videoMiddleName, setVideoMiddleName] = useState('') + const [videoMaidenName, setVideoMaidenName] = useState('') + const [videoDob, setVideoDob] = useState('') + const [videoIdentifying, setVideoIdentifying] = useState(false) + // Store form data per face ID (matching desktop behavior) const [faceFormData, setFaceFormData] = useState { + setVideosLoading(true) + try { + const response = await videosApi.listVideos({ + page: videosPage, + page_size: videosPageSize, + folder_path: videosFolderFilter || undefined, + date_from: videosDateFrom || undefined, + date_to: videosDateTo || undefined, + has_people: videosHasPeople, + person_name: videosPersonName || undefined, + sort_by: videosSortBy, + sort_dir: videosSortDir, + }) + setVideos(response.items) + setVideosTotal(response.total) + } catch (error) { + console.error('Failed to load videos:', error) + alert('Failed to load videos. Please try again.') + } finally { + setVideosLoading(false) + } + } + + const loadVideoPeople = async (videoId: number) => { + setVideoPeopleLoading(true) + try { + const response = await videosApi.getVideoPeople(videoId) + setVideoPeople(response.people) + } catch (error) { + console.error('Failed to load video people:', error) + alert('Failed to load people for this video.') + } finally { + setVideoPeopleLoading(false) + } + } + + const handleVideoSelect = async (video: VideoListItem) => { + setSelectedVideo(video) + await loadVideoPeople(video.id) + // Clear form + setVideoPersonId(undefined) + setVideoFirstName('') + setVideoLastName('') + setVideoMiddleName('') + setVideoMaidenName('') + setVideoDob('') + } + + const handleVideoIdentify = async () => { + if (!selectedVideo) return + + const trimmedFirstName = videoFirstName.trim() + const trimmedLastName = videoLastName.trim() + const trimmedMiddleName = videoMiddleName.trim() + const trimmedMaidenName = videoMaidenName.trim() + const trimmedDob = videoDob.trim() + + if (!videoPersonId && (!trimmedFirstName || !trimmedLastName)) { + alert('Please select an existing person or enter first name and last name.') + return + } + + setVideoIdentifying(true) + try { + const payload: IdentifyVideoRequest = {} + if (videoPersonId) { + payload.person_id = videoPersonId + } else { + payload.first_name = trimmedFirstName + payload.last_name = trimmedLastName + if (trimmedMiddleName) { + payload.middle_name = trimmedMiddleName + } + if (trimmedMaidenName) { + payload.maiden_name = trimmedMaidenName + } + if (trimmedDob) { + payload.date_of_birth = trimmedDob + } + } + + await videosApi.identifyPerson(selectedVideo.id, payload) + + // Refresh video people list + await loadVideoPeople(selectedVideo.id) + + // Refresh videos list to update people count + await loadVideos() + + // Refresh people list if we created a new person + if (!videoPersonId) { + loadPeople() + } + + // Clear form + setVideoPersonId(undefined) + setVideoFirstName('') + setVideoLastName('') + setVideoMiddleName('') + setVideoMaidenName('') + setVideoDob('') + } catch (error: any) { + console.error('Failed to identify person in video:', error) + alert(error.response?.data?.detail || 'Failed to identify person in video. Please try again.') + } finally { + setVideoIdentifying(false) + } + } + + const handleRemovePersonFromVideo = async (personId: number) => { + if (!selectedVideo) return + + if (!confirm('Remove this person from the video?')) { + return + } + + try { + await videosApi.removePerson(selectedVideo.id, personId) + + // Refresh video people list + await loadVideoPeople(selectedVideo.id) + + // Refresh videos list to update people count + await loadVideos() + } catch (error: any) { + console.error('Failed to remove person from video:', error) + alert(error.response?.data?.detail || 'Failed to remove person from video. Please try again.') + } + } + + // Load videos when tab is active + useEffect(() => { + if (activeTab === 'videos') { + loadVideos() + loadPeople() // Load people for the dropdown + } + }, [activeTab, videosPage, videosPageSize, videosFolderFilter, videosDateFrom, videosDateTo, videosHasPeople, videosPersonName, videosSortBy, videosSortDir]) + return (
{photoIds && ( @@ -1329,13 +1497,397 @@ export default function Identify() { )} {activeTab === 'videos' && ( -
-

- Identify People in Videos -

-

- This functionality will be available in a future update. -

+
+ {/* Filters */} +
+ + {!videosFiltersCollapsed && ( +
+
+ + setVideosFolderFilter(e.target.value)} + placeholder="Filter by folder path" + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + setVideosDateFrom(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + setVideosDateTo(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + setVideosPersonName(e.target.value)} + placeholder="Search by person name" + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ +
+ {/* Left: Video List */} +
+
+
+

+ Videos ({videosTotal}) +

+
+ + +
+
+ + {videosLoading ? ( +
Loading videos...
+ ) : videos.length === 0 ? ( +
No videos found
+ ) : ( +
+ {videos.map((video) => ( +
+
handleVideoSelect(video)} + className={`p-3 border rounded-lg cursor-pointer transition ${ + selectedVideo?.id === video.id + ? 'border-indigo-500 bg-indigo-50' + : 'border-gray-200 hover:border-gray-300 hover:bg-gray-50' + }`} + > +
+ {video.filename} { + (e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="60"%3E%3Crect fill="%23ddd" width="80" height="60"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999" font-size="12"%3EVideo%3C/text%3E%3C/svg%3E' + }} + /> +
+
+ {video.filename} +
+ {video.date_taken && ( +
+ {new Date(video.date_taken).toLocaleDateString()} +
+ )} + {video.identified_people_count > 0 && ( +
+ {video.identified_people.slice(0, 3).map((person) => ( + + {person.first_name} {person.last_name} + + ))} + {video.identified_people_count > 3 && ( + + +{video.identified_people_count - 3} more + + )} +
+ )} +
+
+
+ + {/* Identified People - shown directly under selected video */} + {selectedVideo?.id === video.id && ( +
+

+ Identified People ({videoPeople.length}) +

+ {videoPeopleLoading ? ( +
Loading...
+ ) : videoPeople.length === 0 ? ( +
No people identified yet
+ ) : ( +
+ {videoPeople.map((person) => ( +
+
+
+ {person.first_name} {person.last_name} + {person.middle_name && ` ${person.middle_name}`} + {person.maiden_name && ` (${person.maiden_name})`} +
+ {person.identified_by && ( +
+ Identified by {person.identified_by} +
+ )} +
+ +
+ ))} +
+ )} +
+ )} +
+ ))} +
+ )} + + {/* Pagination */} + {videosTotal > 0 && ( +
+
+ Page {videosPage} of {Math.ceil(videosTotal / videosPageSize)} +
+
+ + +
+
+ )} +
+
+ + {/* Right: Video Details and Identification */} +
+ {selectedVideo ? ( +
+
+

+ {selectedVideo.filename} +

+
+ + {/* Add Person Form */} +
+

Add Person

+
+
+ + +
+
OR
+
+
+ + setVideoFirstName(e.target.value)} + disabled={!!videoPersonId} + className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100" + /> +
+
+ + setVideoLastName(e.target.value)} + disabled={!!videoPersonId} + className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100" + /> +
+
+ + setVideoMiddleName(e.target.value)} + disabled={!!videoPersonId} + className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100" + /> +
+
+ + setVideoMaidenName(e.target.value)} + disabled={!!videoPersonId} + className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100" + /> +
+
+ + setVideoDob(e.target.value)} + disabled={!!videoPersonId} + className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100" + /> +
+
+ +
+
+
+ ) : ( +
+

Select a video to identify people

+
+ )} +
+
)}
diff --git a/frontend/src/pages/Modify.tsx b/frontend/src/pages/Modify.tsx index 128e263..f799058 100644 --- a/frontend/src/pages/Modify.tsx +++ b/frontend/src/pages/Modify.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef, useCallback } from 'react' -import peopleApi, { PersonWithFaces, PersonFaceItem, PersonUpdateRequest } from '../api/people' +import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people' import facesApi from '../api/faces' interface EditDialogProps { @@ -149,10 +149,14 @@ export default function Modify() { const [selectedPersonId, setSelectedPersonId] = useState(null) const [selectedPersonName, setSelectedPersonName] = useState('') const [faces, setFaces] = useState([]) + const [videos, setVideos] = useState([]) const [unmatchedFaces, setUnmatchedFaces] = useState>(new Set()) const [unmatchedByPerson, setUnmatchedByPerson] = useState>>({}) const [selectedFaces, setSelectedFaces] = useState>(new Set()) const [editDialogPerson, setEditDialogPerson] = useState(null) + const [deleteDialogPerson, setDeleteDialogPerson] = useState(null) + const [facesExpanded, setFacesExpanded] = useState(true) + const [videosExpanded, setVideosExpanded] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) @@ -173,6 +177,7 @@ export default function Modify() { setSelectedPersonId(firstPerson.id) setSelectedPersonName(formatPersonName(firstPerson)) loadPersonFaces(firstPerson.id) + loadPersonVideos(firstPerson.id) } } catch (err: any) { setError(err.response?.data?.detail || err.message || 'Failed to load people') @@ -198,6 +203,7 @@ export default function Modify() { setSelectedPersonId(null) setSelectedPersonName('') setFaces([]) + setVideos([]) setError(null) // Don't show error for deleted person } else { setError(errorDetail || 'Failed to load faces') @@ -207,18 +213,36 @@ export default function Modify() { } }, [unmatchedFaces]) + // Load videos for a person + const loadPersonVideos = useCallback(async (personId: number) => { + try { + const res = await peopleApi.getVideos(personId) + setVideos(res.items) + } catch (err: any) { + // If person not found, videos will be empty + if (err.response?.status !== 404) { + console.error('Failed to load videos:', err) + } + setVideos([]) + } + }, []) + useEffect(() => { loadPeople() }, [loadPeople]) - // Reload faces when person changes + // Reload faces and videos when person changes useEffect(() => { if (selectedPersonId) { loadPersonFaces(selectedPersonId) + loadPersonVideos(selectedPersonId) // Clear selected faces when person changes setSelectedFaces(new Set()) + } else { + setFaces([]) + setVideos([]) } - }, [selectedPersonId, loadPersonFaces]) + }, [selectedPersonId, loadPersonFaces, loadPersonVideos]) const formatPersonName = (person: PersonWithFaces): string => { const parts: string[] = [] @@ -246,6 +270,7 @@ export default function Modify() { setSelectedPersonId(person.id) setSelectedPersonName(formatPersonName(person)) loadPersonFaces(person.id) + loadPersonVideos(person.id) } const handleEditPerson = (person: PersonWithFaces) => { @@ -256,14 +281,44 @@ export default function Modify() { await peopleApi.update(personId, data) // Reload people list await loadPeople() - // Reload faces if this is the selected person + // Reload faces and videos if this is the selected person if (selectedPersonId === personId) { await loadPersonFaces(personId) + await loadPersonVideos(personId) } setSuccess('Person information updated successfully') setTimeout(() => setSuccess(null), 3000) } + const handleDeletePerson = async () => { + if (!deleteDialogPerson) return + + try { + setBusy(true) + setError(null) + await peopleApi.delete(deleteDialogPerson.id) + + // Clear selection if deleted person was selected + if (selectedPersonId === deleteDialogPerson.id) { + setSelectedPersonId(null) + setSelectedPersonName('') + setFaces([]) + setVideos([]) + } + + // Reload people list + await loadPeople() + + setDeleteDialogPerson(null) + setSuccess(`Person "${formatPersonName(deleteDialogPerson)}" deleted successfully`) + setTimeout(() => setSuccess(null), 3000) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to delete person') + } finally { + setBusy(false) + } + } + const handleUnmatchFace = async (faceId: number) => { // Add to unmatched set (temporary, not persisted until save) const newUnmatched = new Set(unmatchedFaces) @@ -436,28 +491,28 @@ export default function Modify() { : false return ( -
+
{error && ( -
+
{error}
)} {success && ( -
+
{success}
)} -
+
{/* Left panel: People list */} -
-
-

People

+
+
+

People

{/* Search controls */} -
+
{/* People list */} -
+
{busy && people.length === 0 ? (
Loading...
) : people.length === 0 ? ( @@ -517,6 +572,17 @@ export default function Modify() { > {name} ({person.face_count})
+
) })} @@ -526,11 +592,19 @@ export default function Modify() {
- {/* Right panel: Faces grid */} -
-
-
-

Faces

+ {/* Right panel: Faces and Videos split horizontally */} +
+ {/* Top section: Faces grid */} +
+
+
+ {selectedPersonId && (
{visibleFaces.length > 0 && ( @@ -576,58 +650,118 @@ export default function Modify() { )}
- {selectedPersonId ? ( -
- {busy && visibleFaces.length === 0 ? ( -
Loading faces...
- ) : visibleFaces.length === 0 ? ( -
- {faces.length === 0 - ? 'No faces found for this person' - : 'All faces unmatched'} + {facesExpanded && ( + <> + {selectedPersonId ? ( +
+ {busy && visibleFaces.length === 0 ? ( +
Loading faces...
+ ) : visibleFaces.length === 0 ? ( +
+ {faces.length === 0 + ? 'No faces found for this person' + : 'All faces unmatched'} +
+ ) : ( +
+ {visibleFaces.map((face) => ( +
+
+ {`Face { + // Open photo in new window + window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank') + }} + title="Click to show original photo" + onError={(e) => { + e.currentTarget.src = '/placeholder.png' + }} + /> +
+ +
+ ))} +
+ )}
) : ( -
- {visibleFaces.map((face) => ( -
-
- {`Face { - // Open photo in new window - window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank') - }} - title="Click to show original photo" - onError={(e) => { - e.currentTarget.src = '/placeholder.png' - }} - /> -
- -
- ))} +
+
+ Select a person to view their faces +
)} -
- ) : ( -
- Select a person to view their faces -
+ )} +
+
+ + {/* Bottom section: Videos list */} +
+
+
+ +
+ {videosExpanded && ( + <> + {selectedPersonId ? ( +
+ {videos.length === 0 ? ( +
No videos found for this person
+ ) : ( +
+ {videos.map((video) => ( +
{ + // Open video in new window or navigate to video page + window.open(`/api/v1/videos/${video.id}/thumbnail`, '_blank') + }} + title="Click to view video" + > +
{video.filename}
+ {video.date_taken && ( +
+ {new Date(video.date_taken).toLocaleDateString()} +
+ )} +
+ ))} +
+ )} +
+ ) : ( +
+
+ Select a person to view their videos +
+
+ )} + + )} +
@@ -640,6 +774,46 @@ export default function Modify() { onClose={() => setEditDialogPerson(null)} /> )} + + {/* Delete confirmation dialog */} + {deleteDialogPerson && ( +
+
+

Delete Person

+

+ Are you sure you want to delete {formatPersonName(deleteDialogPerson)}? +

+

+ This will: +

    +
  • Unlink all faces from this person
  • +
  • Remove all video linkages
  • +
  • Delete all person encodings
  • +
  • Permanently delete the person record
  • +
+

+

+ This action cannot be undone. +

+
+ + +
+
+
+ )}
) } diff --git a/src/web/api/faces.py b/src/web/api/faces.py index f68acf3..29920fa 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -517,33 +517,31 @@ def batch_unmatch_faces(request: BatchUnmatchRequest, db: Session = Depends(get_ detail=f"Failed to batch unmatch faces: {str(e)}", ) - # After committing, check which people have no faces left and delete them - # This only happens in batch_unmatch (called from Modify Save changes button) - deleted_person_ids = [] - if affected_person_ids: - for person_id in affected_person_ids: - # Check if person has any faces left - face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar() - if face_count == 0: - # Person has no faces left, delete them - person = db.query(Person).filter(Person.id == person_id).first() - if person: - db.delete(person) - deleted_person_ids.append(person_id) - - if deleted_person_ids: - try: - db.commit() - except Exception as e: - db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete people with no faces: {str(e)}", - ) + # Auto-deletion of people without faces is disabled + # People are kept even if they have no identified faces remaining + # deleted_person_ids = [] + # if affected_person_ids: + # for person_id in affected_person_ids: + # # Check if person has any faces left + # face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar() + # if face_count == 0: + # # Person has no faces left, delete them + # person = db.query(Person).filter(Person.id == person_id).first() + # if person: + # db.delete(person) + # deleted_person_ids.append(person_id) + # + # if deleted_person_ids: + # try: + # db.commit() + # except Exception as e: + # db.rollback() + # raise HTTPException( + # status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + # detail=f"Failed to delete people with no faces: {str(e)}", + # ) message = f"Successfully unlinked {len(face_ids_to_unmatch)} face(s)" - if deleted_person_ids: - message += f" and deleted {len(deleted_person_ids)} person(s) with no faces" return BatchUnmatchResponse( unmatched_face_ids=face_ids_to_unmatch, @@ -908,9 +906,8 @@ def delete_faces( This permanently removes faces and their associated encodings. Also removes person_encodings associated with these faces. - If a face is identified (has a person_id), we check if that person will be - left without any faces after deletion. If so, the person is also deleted - from the database. + Note: People are kept even if they have no identified faces remaining + after face deletion. Auto-deletion of people without faces is disabled. """ if not request.face_ids: raise HTTPException( @@ -948,34 +945,31 @@ def delete_faces( detail=f"Failed to delete faces: {str(e)}", ) - # After committing, check which people have no faces left and delete them - # This ensures that if a person was identified by the deleted faces and has - # no other faces remaining, the person is also removed from the database - deleted_person_ids = [] - if affected_person_ids: - for person_id in affected_person_ids: - # Check if person has any faces left after deletion - face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar() - if face_count == 0: - # Person has no faces left, delete them - person = db.query(Person).filter(Person.id == person_id).first() - if person: - db.delete(person) - deleted_person_ids.append(person_id) - - if deleted_person_ids: - try: - db.commit() - except Exception as e: - db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete people with no faces: {str(e)}", - ) + # Auto-deletion of people without faces is disabled + # People are kept even if they have no identified faces remaining + # deleted_person_ids = [] + # if affected_person_ids: + # for person_id in affected_person_ids: + # # Check if person has any faces left after deletion + # face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar() + # if face_count == 0: + # # Person has no faces left, delete them + # person = db.query(Person).filter(Person.id == person_id).first() + # if person: + # db.delete(person) + # deleted_person_ids.append(person_id) + # + # if deleted_person_ids: + # try: + # db.commit() + # except Exception as e: + # db.rollback() + # raise HTTPException( + # status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + # detail=f"Failed to delete people with no faces: {str(e)}", + # ) message = f"Successfully deleted {len(request.face_ids)} face(s)" - if deleted_person_ids: - message += f" and deleted {len(deleted_person_ids)} person(s) with no faces" return DeleteFacesResponse( deleted_face_ids=request.face_ids, diff --git a/src/web/api/people.py b/src/web/api/people.py index 7bca72c..94659ed 100644 --- a/src/web/api/people.py +++ b/src/web/api/people.py @@ -4,12 +4,12 @@ from __future__ import annotations from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import func from sqlalchemy.orm import Session from src.web.db.session import get_db -from src.web.db.models import Person, Face +from src.web.db.models import Person, Face, PersonEncoding, PhotoPersonLinkage, Photo from src.web.api.auth import get_current_user_with_id from src.web.schemas.people import ( PeopleListResponse, @@ -53,17 +53,16 @@ def list_people_with_faces( """List all people with face counts, sorted by last_name, first_name. Optionally filter by last_name or maiden_name if provided (case-insensitive search). - Only returns people who have at least one face. + Returns all people, including those with zero faces. """ - # Query people with face counts + # Query people with face counts using LEFT OUTER JOIN to include people with no faces query = ( db.query( Person, func.count(Face.id).label('face_count') ) - .join(Face, Person.id == Face.person_id) + .outerjoin(Face, Person.id == Face.person_id) .group_by(Person.id) - .having(func.count(Face.id) > 0) ) if last_name: @@ -84,7 +83,7 @@ def list_people_with_faces( middle_name=person.middle_name, maiden_name=person.maiden_name, date_of_birth=person.date_of_birth, - face_count=face_count, + face_count=face_count or 0, # Convert None to 0 for people with no faces ) for person, face_count in results ] @@ -188,6 +187,44 @@ def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFac return PersonFacesResponse(person_id=person_id, items=items, total=len(items)) +@router.get("/{person_id}/videos") +def get_person_videos(person_id: int, db: Session = Depends(get_db)) -> dict: + """Get all videos linked to a specific person.""" + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found") + + # Get all video linkages for this person + linkages = ( + db.query(PhotoPersonLinkage, Photo) + .join(Photo, PhotoPersonLinkage.photo_id == Photo.id) + .filter( + PhotoPersonLinkage.person_id == person_id, + Photo.media_type == "video" + ) + .order_by(Photo.filename) + .all() + ) + + items = [ + { + "id": photo.id, + "filename": photo.filename, + "path": photo.path, + "date_taken": photo.date_taken.isoformat() if photo.date_taken else None, + "date_added": photo.date_added.isoformat() if photo.date_added else None, + "linkage_id": linkage.id, + } + for linkage, photo in linkages + ] + + return { + "person_id": person_id, + "items": items, + "total": len(items), + } + + @router.post("/{person_id}/accept-matches", response_model=IdentifyFaceResponse) def accept_matches( person_id: int, @@ -216,3 +253,45 @@ def accept_matches( created_person=False, ) + +@router.delete("/{person_id}") +def delete_person(person_id: int, db: Session = Depends(get_db)) -> Response: + """Delete a person and all their linkages. + + This will: + 1. Delete all person_encodings for this person + 2. Unlink all faces (set person_id to NULL) + 3. Delete all video linkages (PhotoPersonLinkage records) + 4. Delete the person record + """ + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Person {person_id} not found", + ) + + try: + # Delete all person_encodings for this person + db.query(PersonEncoding).filter(PersonEncoding.person_id == person_id).delete(synchronize_session=False) + + # Unlink all faces (set person_id to NULL) + db.query(Face).filter(Face.person_id == person_id).update( + {"person_id": None}, synchronize_session=False + ) + + # Delete all video linkages (PhotoPersonLinkage records) + db.query(PhotoPersonLinkage).filter(PhotoPersonLinkage.person_id == person_id).delete(synchronize_session=False) + + # Delete the person record + db.delete(person) + + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete person: {str(e)}", + ) + diff --git a/src/web/api/videos.py b/src/web/api/videos.py new file mode 100644 index 0000000..5ddb122 --- /dev/null +++ b/src/web/api/videos.py @@ -0,0 +1,337 @@ +"""Video person identification endpoints.""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from src.web.db.session import get_db +from src.web.db.models import Photo, User +from src.web.api.auth import get_current_user_with_id +from src.web.schemas.videos import ( + ListVideosResponse, + VideoListItem, + PersonInfo, + VideoPeopleResponse, + VideoPersonInfo, + IdentifyVideoRequest, + IdentifyVideoResponse, + RemoveVideoPersonResponse, +) +from src.web.services.video_service import ( + list_videos_for_identification, + get_video_people, + identify_person_in_video, + remove_person_from_video, + get_video_people_count, +) +from src.web.services.thumbnail_service import get_video_thumbnail_path + +router = APIRouter(prefix="/videos", tags=["videos"]) + + +@router.get("", response_model=ListVideosResponse) +def list_videos( + current_user: Annotated[dict, Depends(get_current_user_with_id)], + folder_path: Optional[str] = Query(None, description="Filter by folder path"), + date_from: Optional[str] = Query(None, description="Filter by date taken (from, YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by date taken (to, YYYY-MM-DD)"), + has_people: Optional[bool] = Query(None, description="Filter videos with/without identified people"), + person_name: Optional[str] = Query(None, description="Filter videos containing person with this name"), + sort_by: str = Query("filename", description="Sort field: filename, date_taken, date_added"), + sort_dir: str = Query("asc", description="Sort direction: asc or desc"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), +) -> ListVideosResponse: + """List videos for person identification.""" + # Parse date filters + date_from_parsed = None + date_to_parsed = None + if date_from: + try: + date_from_parsed = date.fromisoformat(date_from) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid date_from format: {date_from}. Use YYYY-MM-DD", + ) + if date_to: + try: + date_to_parsed = date.fromisoformat(date_to) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid date_to format: {date_to}. Use YYYY-MM-DD", + ) + + # Validate sort parameters + if sort_by not in ["filename", "date_taken", "date_added"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sort_by: {sort_by}. Must be filename, date_taken, or date_added", + ) + if sort_dir not in ["asc", "desc"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sort_dir: {sort_dir}. Must be asc or desc", + ) + + # Get videos + videos, total = list_videos_for_identification( + db=db, + folder_path=folder_path, + date_from=date_from_parsed, + date_to=date_to_parsed, + has_people=has_people, + person_name=person_name, + sort_by=sort_by, + sort_dir=sort_dir, + page=page, + page_size=page_size, + ) + + # Build response items + items = [] + for video in videos: + # Get people for this video + people_data = get_video_people(db, video.id) + identified_people = [] + for person, linkage in people_data: + identified_people.append( + PersonInfo( + id=person.id, + first_name=person.first_name, + last_name=person.last_name, + middle_name=person.middle_name, + maiden_name=person.maiden_name, + date_of_birth=person.date_of_birth, + ) + ) + + # Convert date_added to date if it's datetime + date_added = video.date_added + if hasattr(date_added, "date"): + date_added = date_added.date() + + items.append( + VideoListItem( + id=video.id, + filename=video.filename, + path=video.path, + date_taken=video.date_taken, + date_added=date_added, + identified_people=identified_people, + identified_people_count=len(identified_people), + ) + ) + + return ListVideosResponse(items=items, page=page, page_size=page_size, total=total) + + +@router.get("/{video_id}/people", response_model=VideoPeopleResponse) +def get_video_people_endpoint( + video_id: int, + db: Session = Depends(get_db), +) -> VideoPeopleResponse: + """Get all people identified in a video.""" + # Verify video exists + video = db.query(Photo).filter( + Photo.id == video_id, + Photo.media_type == "video" + ).first() + + if not video: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Video {video_id} not found", + ) + + # Get people + people_data = get_video_people(db, video_id) + + people = [] + for person, linkage in people_data: + # Get username if identified_by_user_id exists + username = None + if linkage.identified_by_user_id: + user = db.query(User).filter(User.id == linkage.identified_by_user_id).first() + if user: + username = user.username + + people.append( + VideoPersonInfo( + person_id=person.id, + first_name=person.first_name, + last_name=person.last_name, + middle_name=person.middle_name, + maiden_name=person.maiden_name, + date_of_birth=person.date_of_birth, + identified_by=username, + identified_date=linkage.created_date, + ) + ) + + return VideoPeopleResponse(video_id=video_id, people=people) + + +@router.post("/{video_id}/identify", response_model=IdentifyVideoResponse) +def identify_person_in_video_endpoint( + video_id: int, + request: IdentifyVideoRequest, + current_user: Annotated[dict, Depends(get_current_user_with_id)], + db: Session = Depends(get_db), +) -> IdentifyVideoResponse: + """Identify a person in a video.""" + user_id = current_user.get("id") + + try: + person, created_person = identify_person_in_video( + db=db, + video_id=video_id, + person_id=request.person_id, + first_name=request.first_name, + last_name=request.last_name, + middle_name=request.middle_name, + maiden_name=request.maiden_name, + date_of_birth=request.date_of_birth, + user_id=user_id, + ) + + message = ( + f"Person '{person.first_name} {person.last_name}' identified in video" + if not created_person + else f"Created new person '{person.first_name} {person.last_name}' and identified in video" + ) + + return IdentifyVideoResponse( + video_id=video_id, + person_id=person.id, + created_person=created_person, + message=message, + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.delete("/{video_id}/people/{person_id}", response_model=RemoveVideoPersonResponse) +def remove_person_from_video_endpoint( + video_id: int, + person_id: int, + current_user: Annotated[dict, Depends(get_current_user_with_id)], + db: Session = Depends(get_db), +) -> RemoveVideoPersonResponse: + """Remove person identification from video.""" + try: + removed = remove_person_from_video( + db=db, + video_id=video_id, + person_id=person_id, + ) + + if removed: + return RemoveVideoPersonResponse( + video_id=video_id, + person_id=person_id, + removed=True, + message=f"Person {person_id} removed from video {video_id}", + ) + else: + return RemoveVideoPersonResponse( + video_id=video_id, + person_id=person_id, + removed=False, + message=f"Person {person_id} not found in video {video_id}", + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.get("/{video_id}/thumbnail") +def get_video_thumbnail( + video_id: int, + db: Session = Depends(get_db), +) -> FileResponse: + """Get video thumbnail (generated on-demand and cached).""" + # Verify video exists + video = db.query(Photo).filter( + Photo.id == video_id, + Photo.media_type == "video" + ).first() + + if not video: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Video {video_id} not found", + ) + + # Generate or get cached thumbnail + thumbnail_path = get_video_thumbnail_path(video.path) + + if not thumbnail_path or not thumbnail_path.exists(): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate video thumbnail", + ) + + # Return thumbnail with caching headers + response = FileResponse( + str(thumbnail_path), + media_type="image/jpeg", + ) + response.headers["Cache-Control"] = "public, max-age=86400" # Cache for 1 day + return response + + +@router.get("/{video_id}/video") +def get_video_file( + video_id: int, + db: Session = Depends(get_db), +) -> FileResponse: + """Serve video file for playback.""" + import os + import mimetypes + + # Verify video exists + video = db.query(Photo).filter( + Photo.id == video_id, + Photo.media_type == "video" + ).first() + + if not video: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Video {video_id} not found", + ) + + if not os.path.exists(video.path): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Video file not found: {video.path}", + ) + + # Determine media type from file extension + media_type, _ = mimetypes.guess_type(video.path) + if not media_type or not media_type.startswith('video/'): + media_type = "video/mp4" + + # Use FileResponse with range request support for video streaming + response = FileResponse( + video.path, + media_type=media_type, + ) + response.headers["Content-Disposition"] = "inline" + response.headers["Accept-Ranges"] = "bytes" + response.headers["Cache-Control"] = "public, max-age=3600" + return response + diff --git a/src/web/app.py b/src/web/app.py index ac7ce51..01ab43d 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -25,6 +25,7 @@ from src.web.api.tags import router as tags_router from src.web.api.users import router as users_router from src.web.api.auth_users import router as auth_users_router from src.web.api.role_permissions import router as role_permissions_router +from src.web.api.videos import router as videos_router from src.web.api.version import router as version_router from src.web.settings import APP_TITLE, APP_VERSION from src.web.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES @@ -369,6 +370,67 @@ def ensure_photo_media_type_column(inspector) -> None: print("✅ Added media_type column to photos table") +def ensure_photo_person_linkage_table(inspector) -> None: + """Ensure photo_person_linkage table exists for direct video-person associations.""" + if "photo_person_linkage" in inspector.get_table_names(): + print("â„šī¸ photo_person_linkage table already exists") + return + + print("🔄 Creating photo_person_linkage table...") + dialect = engine.dialect.name + + with engine.connect() as connection: + with connection.begin(): + if dialect == "postgresql": + connection.execute(text(""" + CREATE TABLE IF NOT EXISTS photo_person_linkage ( + id SERIAL PRIMARY KEY, + photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + identified_by_user_id INTEGER REFERENCES users(id), + created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(photo_id, person_id) + ) + """)) + # Create indexes + for idx_name, idx_col in [ + ("idx_photo_person_photo", "photo_id"), + ("idx_photo_person_person", "person_id"), + ("idx_photo_person_user", "identified_by_user_id"), + ]: + try: + connection.execute( + text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON photo_person_linkage({idx_col})") + ) + except Exception: + pass # Index might already exist + else: + # SQLite + connection.execute(text(""" + CREATE TABLE IF NOT EXISTS photo_person_linkage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + identified_by_user_id INTEGER REFERENCES users(id), + created_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(photo_id, person_id) + ) + """)) + # Create indexes + for idx_name, idx_col in [ + ("idx_photo_person_photo", "photo_id"), + ("idx_photo_person_person", "person_id"), + ("idx_photo_person_user", "identified_by_user_id"), + ]: + try: + connection.execute( + text(f"CREATE INDEX {idx_name} ON photo_person_linkage({idx_col})") + ) + except Exception: + pass # Index might already exist + print("✅ Created photo_person_linkage table") + + def ensure_role_permissions_table(inspector) -> None: """Ensure the role_permissions table exists for permission matrix.""" if "role_permissions" in inspector.get_table_names(): @@ -397,7 +459,7 @@ async def lifespan(app: FastAPI): existing_tables = set(inspector.get_table_names()) # Check if required application tables exist (not just alembic_version) - required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites", "users"} + required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites", "users", "photo_person_linkage"} missing_tables = required_tables - existing_tables if missing_tables: @@ -419,6 +481,7 @@ async def lifespan(app: FastAPI): ensure_face_identified_by_user_id_column(inspector) ensure_user_role_column(inspector) ensure_photo_media_type_column(inspector) + ensure_photo_person_linkage_table(inspector) ensure_role_permissions_table(inspector) except Exception as exc: print(f"❌ Database initialization failed: {exc}") @@ -454,6 +517,7 @@ def create_app() -> FastAPI: app.include_router(photos_router, prefix="/api/v1") app.include_router(faces_router, prefix="/api/v1") app.include_router(people_router, prefix="/api/v1") + app.include_router(videos_router, prefix="/api/v1") app.include_router(pending_identifications_router, prefix="/api/v1") app.include_router(pending_linkages_router, prefix="/api/v1") app.include_router(reported_photos_router, prefix="/api/v1") diff --git a/src/web/db/models.py b/src/web/db/models.py index b97fe66..8061245 100644 --- a/src/web/db/models.py +++ b/src/web/db/models.py @@ -48,6 +48,9 @@ class Photo(Base): "PhotoTagLinkage", back_populates="photo", cascade="all, delete-orphan" ) favorites = relationship("PhotoFavorite", back_populates="photo", cascade="all, delete-orphan") + video_people = relationship( + "PhotoPersonLinkage", back_populates="photo", cascade="all, delete-orphan" + ) __table_args__ = ( Index("idx_photos_processed", "processed"), @@ -74,6 +77,9 @@ class Person(Base): person_encodings = relationship( "PersonEncoding", back_populates="person", cascade="all, delete-orphan" ) + video_photos = relationship( + "PhotoPersonLinkage", back_populates="person", cascade="all, delete-orphan" + ) __table_args__ = ( UniqueConstraint( @@ -235,6 +241,32 @@ class User(Base): ) +class PhotoPersonLinkage(Base): + """Direct linkage between Video (Photo with media_type='video') and Person. + + This allows identifying people in videos without requiring face detection. + Only used for videos, not photos (photos use Face model for identification). + """ + + __tablename__ = "photo_person_linkage" + + id = Column(Integer, primary_key=True, autoincrement=True) + photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True) + person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True) + identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + created_date = Column(DateTime, default=datetime.utcnow, nullable=False) + + photo = relationship("Photo", back_populates="video_people") + person = relationship("Person", back_populates="video_photos") + + __table_args__ = ( + UniqueConstraint("photo_id", "person_id", name="uq_photo_person"), + Index("idx_photo_person_photo", "photo_id"), + Index("idx_photo_person_person", "person_id"), + Index("idx_photo_person_user", "identified_by_user_id"), + ) + + class RolePermission(Base): """Role-to-feature permission matrix.""" diff --git a/src/web/schemas/videos.py b/src/web/schemas/videos.py new file mode 100644 index 0000000..fce5373 --- /dev/null +++ b/src/web/schemas/videos.py @@ -0,0 +1,90 @@ +"""Video schemas for person identification.""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import List, Optional + +from pydantic import BaseModel + + +class PersonInfo(BaseModel): + """Person information for video listings.""" + + id: int + first_name: str + last_name: str + middle_name: Optional[str] = None + maiden_name: Optional[str] = None + date_of_birth: Optional[date] = None + + +class VideoListItem(BaseModel): + """Video item in list response.""" + + id: int + filename: str + path: str + date_taken: Optional[date] = None + date_added: date + identified_people: List[PersonInfo] + identified_people_count: int + + +class ListVideosResponse(BaseModel): + """Response for listing videos.""" + + items: List[VideoListItem] + page: int + page_size: int + total: int + + +class VideoPersonInfo(BaseModel): + """Person information with identification metadata.""" + + person_id: int + first_name: str + last_name: str + middle_name: Optional[str] = None + maiden_name: Optional[str] = None + date_of_birth: Optional[date] = None + identified_by: Optional[str] = None # Username + identified_date: datetime + + +class VideoPeopleResponse(BaseModel): + """Response for getting people in a video.""" + + video_id: int + people: List[VideoPersonInfo] + + +class IdentifyVideoRequest(BaseModel): + """Request to identify a person in a video.""" + + person_id: Optional[int] = None # Use existing person + first_name: Optional[str] = None # Create new person + last_name: Optional[str] = None + middle_name: Optional[str] = None + maiden_name: Optional[str] = None + date_of_birth: Optional[date] = None + + +class IdentifyVideoResponse(BaseModel): + """Response for identifying a person in a video.""" + + video_id: int + person_id: int + created_person: bool + message: str + + +class RemoveVideoPersonResponse(BaseModel): + """Response for removing a person from a video.""" + + video_id: int + person_id: int + removed: bool + message: str + diff --git a/src/web/services/thumbnail_service.py b/src/web/services/thumbnail_service.py new file mode 100644 index 0000000..016d4fd --- /dev/null +++ b/src/web/services/thumbnail_service.py @@ -0,0 +1,176 @@ +"""Video thumbnail generation service with caching.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from typing import Optional + +from PIL import Image + + +# Cache directory for thumbnails (relative to project root) +# Will be created in the same directory as the database +THUMBNAIL_CACHE_DIR = Path(__file__).parent.parent.parent.parent / "data" / "thumbnails" +THUMBNAIL_SIZE = (320, 240) # Width, Height +THUMBNAIL_QUALITY = 85 # JPEG quality + + +def get_thumbnail_cache_path(video_path: str) -> Path: + """Get cache path for a video thumbnail. + + Args: + video_path: Full path to video file + + Returns: + Path to cached thumbnail file + """ + # Create hash of video path for cache filename + path_hash = hashlib.md5(video_path.encode()).hexdigest() + # Use original filename (without extension) + hash for uniqueness + video_file = Path(video_path) + cache_filename = f"{video_file.stem}_{path_hash[:8]}.jpg" + + # Ensure cache directory exists + THUMBNAIL_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + return THUMBNAIL_CACHE_DIR / cache_filename + + +def generate_video_thumbnail( + video_path: str, + force_regenerate: bool = False, +) -> Optional[Path]: + """Generate thumbnail for a video file. + + Extracts first frame and creates a cached thumbnail. + + Args: + video_path: Full path to video file + force_regenerate: If True, regenerate even if cached thumbnail exists + + Returns: + Path to thumbnail file, or None if generation failed + """ + if not os.path.exists(video_path): + return None + + cache_path = get_thumbnail_cache_path(video_path) + + # Return cached thumbnail if it exists and we're not forcing regeneration + if cache_path.exists() and not force_regenerate: + return cache_path + + try: + # Try to use OpenCV first (faster, more reliable) + try: + import cv2 + + # Open video file + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + return None + + # Read first frame + ret, frame = cap.read() + cap.release() + + if not ret or frame is None: + return None + + # Convert BGR to RGB (OpenCV uses BGR) + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Convert to PIL Image + image = Image.fromarray(frame_rgb) + + except ImportError: + # Fallback to ffmpeg if OpenCV not available + import subprocess + import tempfile + + # Extract first frame using ffmpeg + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_file: + tmp_path = tmp_file.name + + try: + # Use ffmpeg to extract first frame + result = subprocess.run( + [ + "ffmpeg", + "-i", video_path, + "-vframes", "1", + "-q:v", "2", # High quality + "-y", # Overwrite output + tmp_path, + ], + capture_output=True, + timeout=30, # 30 second timeout + ) + + if result.returncode != 0: + return None + + if not os.path.exists(tmp_path): + return None + + # Load with PIL + image = Image.open(tmp_path) + os.unlink(tmp_path) # Clean up temp file + + except (subprocess.TimeoutExpired, FileNotFoundError, Exception): + # ffmpeg not available or failed + if os.path.exists(tmp_path): + os.unlink(tmp_path) + return None + + # Resize to thumbnail size (maintain aspect ratio) + image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS) + + # Convert to RGB if needed (for JPEG) + if image.mode != "RGB": + image = image.convert("RGB") + + # Save to cache + image.save(cache_path, "JPEG", quality=THUMBNAIL_QUALITY, optimize=True) + + return cache_path + + except Exception as e: + # Log error but don't fail + print(f"âš ī¸ Failed to generate thumbnail for {video_path}: {e}") + return None + + +def get_video_thumbnail_path(video_path: str) -> Optional[Path]: + """Get thumbnail path for a video, generating if needed. + + Args: + video_path: Full path to video file + + Returns: + Path to thumbnail file, or None if generation failed + """ + return generate_video_thumbnail(video_path, force_regenerate=False) + + +def clear_thumbnail_cache() -> int: + """Clear all cached thumbnails. + + Returns: + Number of files deleted + """ + if not THUMBNAIL_CACHE_DIR.exists(): + return 0 + + count = 0 + for file in THUMBNAIL_CACHE_DIR.glob("*.jpg"): + try: + file.unlink() + count += 1 + except Exception: + pass + + return count + diff --git a/src/web/services/video_service.py b/src/web/services/video_service.py new file mode 100644 index 0000000..411e6d5 --- /dev/null +++ b/src/web/services/video_service.py @@ -0,0 +1,326 @@ +"""Video service for managing video-person identifications.""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import List, Optional, Tuple + +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session + +from src.web.db.models import Photo, Person, PhotoPersonLinkage, User + + +def list_videos_for_identification( + db: Session, + folder_path: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + has_people: Optional[bool] = None, + person_name: Optional[str] = None, + sort_by: str = "filename", + sort_dir: str = "asc", + page: int = 1, + page_size: int = 50, +) -> Tuple[List[Photo], int]: + """List videos for person identification. + + Args: + db: Database session + folder_path: Filter by folder path (starts with) + date_from: Filter by date taken (from) + date_to: Filter by date taken (to) + has_people: Filter videos with/without identified people (True/False/None) + person_name: Filter videos containing person with this name + sort_by: Sort field ("filename", "date_taken", "date_added") + sort_dir: Sort direction ("asc" or "desc") + page: Page number (1-based) + page_size: Items per page + + Returns: + Tuple of (videos list, total count) + """ + # Base query: only videos + query = db.query(Photo).filter(Photo.media_type == "video") + + # Apply folder filter + if folder_path: + folder_path = folder_path.strip() + query = query.filter(Photo.path.startswith(folder_path)) + + # Apply date filters + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + + # Apply person name filter + if person_name: + person_name_lower = person_name.lower().strip() + # Search in first_name, last_name, middle_name, maiden_name + matching_people = ( + db.query(Person.id) + .filter( + or_( + func.lower(Person.first_name).contains(person_name_lower), + func.lower(Person.last_name).contains(person_name_lower), + func.lower(Person.middle_name).contains(person_name_lower), + func.lower(Person.maiden_name).contains(person_name_lower), + ) + ) + .subquery() + ) + # Get videos linked to these people + video_ids_with_person = ( + db.query(PhotoPersonLinkage.photo_id) + .filter(PhotoPersonLinkage.person_id.in_(db.query(matching_people.c.id))) + .subquery() + ) + query = query.filter(Photo.id.in_(db.query(video_ids_with_person.c.photo_id))) + + # Apply has_people filter + if has_people is not None: + # Subquery to get video IDs with people + videos_with_people = ( + db.query(PhotoPersonLinkage.photo_id) + .distinct() + .subquery() + ) + if has_people: + query = query.filter(Photo.id.in_(db.query(videos_with_people.c.photo_id))) + else: + query = query.filter(~Photo.id.in_(db.query(videos_with_people.c.photo_id))) + + # Get total count before pagination + total = query.count() + + # Apply sorting + if sort_by == "filename": + order_col = Photo.filename + elif sort_by == "date_taken": + order_col = Photo.date_taken + elif sort_by == "date_added": + order_col = Photo.date_added + else: + order_col = Photo.filename + + if sort_dir.lower() == "desc": + query = query.order_by(order_col.desc()) + else: + query = query.order_by(order_col.asc()) + + # Apply pagination + offset = (page - 1) * page_size + results = query.offset(offset).limit(page_size).all() + + return results, total + + +def get_video_people( + db: Session, + video_id: int, +) -> List[Tuple[Person, PhotoPersonLinkage]]: + """Get all people identified in a video. + + Args: + db: Database session + video_id: Video (Photo) ID + + Returns: + List of (Person, PhotoPersonLinkage) tuples + """ + # Verify it's a video + video = db.query(Photo).filter( + Photo.id == video_id, + Photo.media_type == "video" + ).first() + + if not video: + return [] + + # Get all linkages for this video + linkages = ( + db.query(PhotoPersonLinkage, Person) + .join(Person, PhotoPersonLinkage.person_id == Person.id) + .filter(PhotoPersonLinkage.photo_id == video_id) + .order_by(Person.last_name, Person.first_name) + .all() + ) + + return [(person, linkage) for linkage, person in linkages] + + +def identify_person_in_video( + db: Session, + video_id: int, + person_id: Optional[int] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + middle_name: Optional[str] = None, + maiden_name: Optional[str] = None, + date_of_birth: Optional[date] = None, + user_id: Optional[int] = None, +) -> Tuple[Person, bool]: + """Identify a person in a video. + + Args: + db: Database session + video_id: Video (Photo) ID + person_id: Existing person ID (if using existing person) + first_name: First name (if creating new person) + last_name: Last name (if creating new person) + middle_name: Middle name (optional) + maiden_name: Maiden name (optional) + date_of_birth: Date of birth (optional) + user_id: User ID who is identifying (optional) + + Returns: + Tuple of (Person, created_person: bool) + + Raises: + ValueError: If video doesn't exist or is not a video, or if person data is invalid + """ + # Verify video exists and is actually a video + video = db.query(Photo).filter( + Photo.id == video_id, + Photo.media_type == "video" + ).first() + + if not video: + raise ValueError(f"Video {video_id} not found or is not a video") + + # Get or create person + person: Optional[Person] = None + created_person = False + + if person_id: + # Use existing person + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise ValueError(f"Person {person_id} not found") + else: + # Create new person + if not first_name or not last_name: + raise ValueError("first_name and last_name are required to create a person") + + first_name = first_name.strip() + last_name = last_name.strip() + middle_name = middle_name.strip() if middle_name else None + maiden_name = maiden_name.strip() if maiden_name else None + + # Check if person already exists (unique constraint) + existing_person = ( + db.query(Person) + .filter( + Person.first_name == first_name, + Person.last_name == last_name, + Person.middle_name == middle_name, + Person.maiden_name == maiden_name, + Person.date_of_birth == date_of_birth, + ) + .first() + ) + + if existing_person: + person = existing_person + else: + person = Person( + first_name=first_name, + last_name=last_name, + middle_name=middle_name, + maiden_name=maiden_name, + date_of_birth=date_of_birth, + ) + db.add(person) + db.flush() # Get person.id + created_person = True + # Commit the person creation immediately to ensure it's saved + db.commit() + + # Check if linkage already exists + existing_linkage = ( + db.query(PhotoPersonLinkage) + .filter( + PhotoPersonLinkage.photo_id == video_id, + PhotoPersonLinkage.person_id == person.id, + ) + .first() + ) + + if not existing_linkage: + # Create new linkage + linkage = PhotoPersonLinkage( + photo_id=video_id, + person_id=person.id, + identified_by_user_id=user_id, + ) + db.add(linkage) + db.commit() + + # If person was already committed above, this commit is just for the linkage + # If person already existed, this commit does nothing (no pending changes) + # This ensures the linkage is saved + return person, created_person + + +def remove_person_from_video( + db: Session, + video_id: int, + person_id: int, +) -> bool: + """Remove person identification from video. + + Args: + db: Database session + video_id: Video (Photo) ID + person_id: Person ID to remove + + Returns: + True if removed, False if not found + + Raises: + ValueError: If video doesn't exist or is not a video + """ + # Verify video exists and is actually a video + video = db.query(Photo).filter( + Photo.id == video_id, + Photo.media_type == "video" + ).first() + + if not video: + raise ValueError(f"Video {video_id} not found or is not a video") + + # Find and delete linkage + linkage = ( + db.query(PhotoPersonLinkage) + .filter( + PhotoPersonLinkage.photo_id == video_id, + PhotoPersonLinkage.person_id == person_id, + ) + .first() + ) + + if linkage: + db.delete(linkage) + db.commit() + return True + + return False + + +def get_video_people_count(db: Session, video_id: int) -> int: + """Get count of people identified in a video. + + Args: + db: Database session + video_id: Video (Photo) ID + + Returns: + Count of people + """ + return ( + db.query(PhotoPersonLinkage) + .filter(PhotoPersonLinkage.photo_id == video_id) + .count() + ) +