feat: Add video management features and API endpoints for person identification
This commit introduces several enhancements related to video management, including the addition of a new API for handling video-person identifications. The frontend has been updated to support video listing, person identification in videos, and the ability to remove person identifications. A new database table, photo_person_linkage, has been created to manage the relationships between videos and identified persons. Additionally, video thumbnail generation has been implemented, improving the user experience when interacting with video content. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
c6055737fb
commit
9d40f9772e
BIN
data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4
Normal file
BIN
data/uploads/c38b1ae2-ac71-442e-ac3b-ba11d1d8ba36.mp4
Normal file
Binary file not shown.
@ -62,10 +62,17 @@ export const peopleApi = {
|
||||
const res = await apiClient.get<PersonFacesResponse>(`/api/v1/people/${personId}/faces`)
|
||||
return res.data
|
||||
},
|
||||
getVideos: async (personId: number): Promise<PersonVideosResponse> => {
|
||||
const res = await apiClient.get<PersonVideosResponse>(`/api/v1/people/${personId}/videos`)
|
||||
return res.data
|
||||
},
|
||||
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
|
||||
const res = await apiClient.post<IdentifyFaceResponse>(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds })
|
||||
return res.data
|
||||
},
|
||||
delete: async (personId: number): Promise<void> => {
|
||||
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
|
||||
|
||||
|
||||
|
||||
122
frontend/src/api/videos.ts
Normal file
122
frontend/src/api/videos.ts
Normal file
@ -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<ListVideosResponse> => {
|
||||
const res = await apiClient.get<ListVideosResponse>('/api/v1/videos', { params })
|
||||
return res.data
|
||||
},
|
||||
|
||||
getVideoPeople: async (videoId: number): Promise<VideoPeopleResponse> => {
|
||||
const res = await apiClient.get<VideoPeopleResponse>(`/api/v1/videos/${videoId}/people`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
identifyPerson: async (
|
||||
videoId: number,
|
||||
request: IdentifyVideoRequest
|
||||
): Promise<IdentifyVideoResponse> => {
|
||||
const res = await apiClient.post<IdentifyVideoResponse>(
|
||||
`/api/v1/videos/${videoId}/identify`,
|
||||
request
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
removePerson: async (
|
||||
videoId: number,
|
||||
personId: number
|
||||
): Promise<RemoveVideoPersonResponse> => {
|
||||
const res = await apiClient.delete<RemoveVideoPersonResponse>(
|
||||
`/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
|
||||
|
||||
@ -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<VideoListItem[]>([])
|
||||
const [videosTotal, setVideosTotal] = useState(0)
|
||||
const [videosPage, setVideosPage] = useState(1)
|
||||
const [videosPageSize, setVideosPageSize] = useState(50)
|
||||
const [videosLoading, setVideosLoading] = useState(false)
|
||||
const [selectedVideo, setSelectedVideo] = useState<VideoListItem | null>(null)
|
||||
const [videoPeople, setVideoPeople] = useState<VideoPersonInfo[]>([])
|
||||
const [videoPeopleLoading, setVideoPeopleLoading] = useState(false)
|
||||
const [videosFolderFilter, setVideosFolderFilter] = useState<string>('')
|
||||
const [videosDateFrom, setVideosDateFrom] = useState<string>('')
|
||||
const [videosDateTo, setVideosDateTo] = useState<string>('')
|
||||
const [videosHasPeople, setVideosHasPeople] = useState<boolean | undefined>(undefined)
|
||||
const [videosPersonName, setVideosPersonName] = useState<string>('')
|
||||
const [videosSortBy, setVideosSortBy] = useState<string>('filename')
|
||||
const [videosSortDir, setVideosSortDir] = useState<string>('asc')
|
||||
const [videosFiltersCollapsed, setVideosFiltersCollapsed] = useState(true)
|
||||
|
||||
// Person identification form state
|
||||
const [videoPersonId, setVideoPersonId] = useState<number | undefined>(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<Record<number, {
|
||||
personId?: number
|
||||
@ -694,6 +722,146 @@ export default function Identify() {
|
||||
return `Face ${currentIdx + 1} of ${faces.length}`
|
||||
}, [currentFace, currentIdx, faces.length])
|
||||
|
||||
// Video functions
|
||||
const loadVideos = async () => {
|
||||
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 (
|
||||
<div>
|
||||
{photoIds && (
|
||||
@ -1329,13 +1497,397 @@ export default function Identify() {
|
||||
)}
|
||||
|
||||
{activeTab === 'videos' && (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">
|
||||
Identify People in Videos
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
This functionality will be available in a future update.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<button
|
||||
onClick={() => setVideosFiltersCollapsed(!videosFiltersCollapsed)}
|
||||
className="flex items-center justify-between w-full text-left font-medium text-gray-700"
|
||||
>
|
||||
<span>Filters</span>
|
||||
<span>{videosFiltersCollapsed ? '▼' : '▲'}</span>
|
||||
</button>
|
||||
{!videosFiltersCollapsed && (
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Folder Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videosFolderFilter}
|
||||
onChange={(e) => setVideosFolderFilter(e.target.value)}
|
||||
placeholder="Filter by folder path"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={videosDateFrom}
|
||||
onChange={(e) => setVideosDateFrom(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={videosDateTo}
|
||||
onChange={(e) => setVideosDateTo(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Person Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videosPersonName}
|
||||
onChange={(e) => setVideosPersonName(e.target.value)}
|
||||
placeholder="Search by person name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Has People
|
||||
</label>
|
||||
<select
|
||||
value={videosHasPeople === undefined ? 'all' : videosHasPeople ? 'yes' : 'no'}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setVideosHasPeople(val === 'all' ? undefined : val === 'yes')
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="yes">With People</option>
|
||||
<option value="no">Without People</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
value={videosSortBy}
|
||||
onChange={(e) => setVideosSortBy(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="filename">Filename</option>
|
||||
<option value="date_taken">Date Taken</option>
|
||||
<option value="date_added">Date Added</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sort Direction
|
||||
</label>
|
||||
<select
|
||||
value={videosSortDir}
|
||||
onChange={(e) => setVideosSortDir(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="asc">Ascending</option>
|
||||
<option value="desc">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Video List */}
|
||||
<div className="col-span-5">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">
|
||||
Videos ({videosTotal})
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Page Size:</label>
|
||||
<select
|
||||
value={videosPageSize}
|
||||
onChange={(e) => {
|
||||
setVideosPageSize(Number(e.target.value))
|
||||
setVideosPage(1)
|
||||
}}
|
||||
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videosLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Loading videos...</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No videos found</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{videos.map((video) => (
|
||||
<div key={video.id}>
|
||||
<div
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={videosApi.getThumbnailUrl(video.id)}
|
||||
alt={video.filename}
|
||||
className="w-20 h-15 object-cover rounded"
|
||||
onError={(e) => {
|
||||
(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'
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 truncate">
|
||||
{video.filename}
|
||||
</div>
|
||||
{video.date_taken && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(video.date_taken).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
{video.identified_people_count > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{video.identified_people.slice(0, 3).map((person) => (
|
||||
<span
|
||||
key={person.id}
|
||||
className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded"
|
||||
>
|
||||
{person.first_name} {person.last_name}
|
||||
</span>
|
||||
))}
|
||||
{video.identified_people_count > 3 && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
+{video.identified_people_count - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identified People - shown directly under selected video */}
|
||||
{selectedVideo?.id === video.id && (
|
||||
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-700 mb-2 text-sm">
|
||||
Identified People ({videoPeople.length})
|
||||
</h4>
|
||||
{videoPeopleLoading ? (
|
||||
<div className="text-sm text-gray-500 text-center py-2">Loading...</div>
|
||||
) : videoPeople.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-2">No people identified yet</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{videoPeople.map((person) => (
|
||||
<div
|
||||
key={person.person_id}
|
||||
className="flex items-center justify-between p-2 bg-white rounded border border-gray-200"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 text-sm truncate">
|
||||
{person.first_name} {person.last_name}
|
||||
{person.middle_name && ` ${person.middle_name}`}
|
||||
{person.maiden_name && ` (${person.maiden_name})`}
|
||||
</div>
|
||||
{person.identified_by && (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
Identified by {person.identified_by}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemovePersonFromVideo(person.person_id)}
|
||||
className="ml-2 px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded flex-shrink-0"
|
||||
title="Remove person"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{videosTotal > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {videosPage} of {Math.ceil(videosTotal / videosPageSize)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setVideosPage((p) => Math.max(1, p - 1))}
|
||||
disabled={videosPage === 1}
|
||||
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVideosPage((p) => p + 1)}
|
||||
disabled={videosPage >= Math.ceil(videosTotal / videosPageSize)}
|
||||
className="px-3 py-1 border border-gray-300 rounded disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Video Details and Identification */}
|
||||
<div className="col-span-7">
|
||||
{selectedVideo ? (
|
||||
<div className="bg-white rounded-lg shadow p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">
|
||||
{selectedVideo.filename}
|
||||
</h3>
|
||||
<video
|
||||
src={videosApi.getVideoUrl(selectedVideo.id)}
|
||||
controls
|
||||
className="w-full max-h-64 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add Person Form */}
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium text-gray-700 mb-3">Add Person</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Existing Person
|
||||
</label>
|
||||
<select
|
||||
value={videoPersonId || ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setVideoPersonId(val ? Number(val) : undefined)
|
||||
if (val) {
|
||||
const person = people.find((p) => p.id === Number(val))
|
||||
if (person) {
|
||||
setVideoFirstName('')
|
||||
setVideoLastName('')
|
||||
setVideoMiddleName('')
|
||||
setVideoMaidenName('')
|
||||
setVideoDob('')
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">-- Select existing person --</option>
|
||||
{people.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.first_name} {person.last_name}
|
||||
{person.middle_name && ` ${person.middle_name}`}
|
||||
{person.maiden_name && ` (${person.maiden_name})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 text-center">OR</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoFirstName}
|
||||
onChange={(e) => setVideoFirstName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoLastName}
|
||||
onChange={(e) => setVideoLastName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Middle Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoMiddleName}
|
||||
onChange={(e) => setVideoMiddleName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Maiden Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={videoMaidenName}
|
||||
onChange={(e) => setVideoMaidenName(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date of Birth
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={videoDob}
|
||||
onChange={(e) => setVideoDob(e.target.value)}
|
||||
disabled={!!videoPersonId}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleVideoIdentify}
|
||||
disabled={videoIdentifying || (!videoPersonId && (!videoFirstName.trim() || !videoLastName.trim()))}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{videoIdentifying ? 'Adding...' : 'Add Person to Video'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<p className="text-gray-500">Select a video to identify people</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<number | null>(null)
|
||||
const [selectedPersonName, setSelectedPersonName] = useState('')
|
||||
const [faces, setFaces] = useState<PersonFaceItem[]>([])
|
||||
const [videos, setVideos] = useState<PersonVideoItem[]>([])
|
||||
const [unmatchedFaces, setUnmatchedFaces] = useState<Set<number>>(new Set())
|
||||
const [unmatchedByPerson, setUnmatchedByPerson] = useState<Record<number, Set<number>>>({})
|
||||
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
|
||||
const [editDialogPerson, setEditDialogPerson] = useState<PersonWithFaces | null>(null)
|
||||
const [deleteDialogPerson, setDeleteDialogPerson] = useState<PersonWithFaces | null>(null)
|
||||
const [facesExpanded, setFacesExpanded] = useState(true)
|
||||
const [videosExpanded, setVideosExpanded] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col" style={{ height: 'calc(100vh - 3rem - 2rem)', overflow: 'hidden' }}>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded text-red-700 flex-shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded text-green-700">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded text-green-700 flex-shrink-0">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-3 gap-6 flex-1" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
{/* Left panel: People list */}
|
||||
<div className="col-span-1">
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">People</h2>
|
||||
<div className="col-span-1" style={{ minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<h2 className="text-lg font-semibold mb-4 flex-shrink-0">People</h2>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<div className="flex gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
@ -484,7 +539,7 @@ export default function Modify() {
|
||||
</div>
|
||||
|
||||
{/* People list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1" style={{ minHeight: 0, overflowY: 'auto' }}>
|
||||
{busy && people.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading...</div>
|
||||
) : people.length === 0 ? (
|
||||
@ -517,6 +572,17 @@ export default function Modify() {
|
||||
>
|
||||
{name} ({person.face_count})
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteDialogPerson(person)
|
||||
}}
|
||||
className="text-sm px-2 py-1 hover:bg-red-100 text-red-600 rounded"
|
||||
title="Delete person"
|
||||
disabled={busy}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -526,11 +592,19 @@ export default function Modify() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Faces grid */}
|
||||
<div className="col-span-2">
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Faces</h2>
|
||||
{/* Right panel: Faces and Videos split horizontally */}
|
||||
<div className="col-span-2" style={{ minHeight: 0, display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{/* Top section: Faces grid */}
|
||||
<div className={facesExpanded ? "flex-1" : ""} style={{ minHeight: 0, display: 'flex', flexDirection: 'column', flexShrink: facesExpanded ? 1 : 0 }}>
|
||||
<div className={`bg-white rounded-lg shadow p-4 flex flex-col ${facesExpanded ? 'h-full' : ''}`} style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setFacesExpanded(!facesExpanded)}
|
||||
className="flex items-center gap-2 hover:bg-gray-100 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-lg font-semibold">Faces</span>
|
||||
<span>{facesExpanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{selectedPersonId && (
|
||||
<div className="flex gap-2">
|
||||
{visibleFaces.length > 0 && (
|
||||
@ -576,58 +650,118 @@ export default function Modify() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{busy && visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading faces...</div>
|
||||
) : visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{faces.length === 0
|
||||
? 'No faces found for this person'
|
||||
: 'All faces unmatched'}
|
||||
{facesExpanded && (
|
||||
<>
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1" style={{ minHeight: 0, overflowY: 'auto' }}>
|
||||
{busy && visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">Loading faces...</div>
|
||||
) : visibleFaces.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{faces.length === 0
|
||||
? 'No faces found for this person'
|
||||
: 'All faces unmatched'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
{visibleFaces.map((face) => (
|
||||
<div key={face.id} className="flex flex-col items-center">
|
||||
<div className="w-20 h-20 mb-2">
|
||||
<img
|
||||
src={`/api/v1/faces/${face.id}/crop`}
|
||||
alt={`Face ${face.id}`}
|
||||
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => {
|
||||
// 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'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces.has(face.id)}
|
||||
onChange={() => handleToggleFaceSelection(face.id)}
|
||||
className="rounded"
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="text-xs text-gray-700">Unmatch</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
{visibleFaces.map((face) => (
|
||||
<div key={face.id} className="flex flex-col items-center">
|
||||
<div className="w-20 h-20 mb-2">
|
||||
<img
|
||||
src={`/api/v1/faces/${face.id}/crop`}
|
||||
alt={`Face ${face.id}`}
|
||||
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => {
|
||||
// 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'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces.has(face.id)}
|
||||
onChange={() => handleToggleFaceSelection(face.id)}
|
||||
className="rounded"
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="text-xs text-gray-700">Unmatch</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their faces
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their faces
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section: Videos list */}
|
||||
<div className={videosExpanded ? "flex-1" : ""} style={{ minHeight: 0, display: 'flex', flexDirection: 'column', flexShrink: videosExpanded ? 1 : 0 }}>
|
||||
<div className={`bg-white rounded-lg shadow p-4 flex flex-col ${videosExpanded ? 'h-full' : ''}`} style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setVideosExpanded(!videosExpanded)}
|
||||
className="flex items-center gap-2 hover:bg-gray-100 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-lg font-semibold">Videos</span>
|
||||
<span>{videosExpanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{videosExpanded && (
|
||||
<>
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1" style={{ minHeight: 0, overflowY: 'auto' }}>
|
||||
{videos.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">No videos found for this person</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{videos.map((video) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className="p-3 border border-gray-200 rounded hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => {
|
||||
// Open video in new window or navigate to video page
|
||||
window.open(`/api/v1/videos/${video.id}/thumbnail`, '_blank')
|
||||
}}
|
||||
title="Click to view video"
|
||||
>
|
||||
<div className="font-medium text-sm truncate">{video.filename}</div>
|
||||
{video.date_taken && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{new Date(video.date_taken).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Select a person to view their videos
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -640,6 +774,46 @@ export default function Modify() {
|
||||
onClose={() => setEditDialogPerson(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteDialogPerson && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h2 className="text-xl font-bold mb-4 text-red-600">Delete Person</h2>
|
||||
<p className="mb-4">
|
||||
Are you sure you want to delete <strong>{formatPersonName(deleteDialogPerson)}</strong>?
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
This will:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Unlink all faces from this person</li>
|
||||
<li>Remove all video linkages</li>
|
||||
<li>Delete all person encodings</li>
|
||||
<li>Permanently delete the person record</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p className="mb-6 text-sm font-semibold text-red-600">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteDialogPerson(null)}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeletePerson}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}",
|
||||
)
|
||||
|
||||
|
||||
337
src/web/api/videos.py
Normal file
337
src/web/api/videos.py
Normal file
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
90
src/web/schemas/videos.py
Normal file
90
src/web/schemas/videos.py
Normal file
@ -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
|
||||
|
||||
176
src/web/services/thumbnail_service.py
Normal file
176
src/web/services/thumbnail_service.py
Normal file
@ -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
|
||||
|
||||
326
src/web/services/video_service.py
Normal file
326
src/web/services/video_service.py
Normal file
@ -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()
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user