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:
tanyar09
2025-12-02 15:14:18 -05:00
parent c6055737fb
commit 9d40f9772e
13 changed files with 2102 additions and 134 deletions
+48 -54
View File
@@ -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,
+86 -7
View File
@@ -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
View 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
+65 -1
View File
@@ -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")
+32
View File
@@ -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
View 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
View 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
View 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()
)