Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
THUMBNAIL_IMAGES_PATH = os.path.join(user_data_dir("PictoPy"), "thumbnails")
IMAGES_PATH = "./images"

# Videos storage
VIDEOS_PATH = "./videos"
THUMBNAIL_VIDEOS_PATH = "./videos/thumbnails"
9 changes: 7 additions & 2 deletions backend/app/database/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,18 +457,22 @@ def db_toggle_image_favourite_status(image_id: str) -> bool:
finally:
conn.close()


def db_get_image_by_id(image_id: str) -> Optional[dict]:
"""
Get a single image by ID with its favorite status.
"""
conn = _connect()
cursor = conn.cursor()
try:
cursor.execute("""
cursor.execute(
"""
SELECT id, path, folder_id, thumbnailPath, metadata, isTagged, isFavourite
FROM images
WHERE id = ?
""", (image_id,))
""",
(image_id,),
)
row = cursor.fetchone()
if not row:
return None
Expand All @@ -488,6 +492,7 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]:
finally:
conn.close()


# ============================================================================
# MEMORIES FEATURE - Location and Time-based Queries
# ============================================================================
Expand Down
171 changes: 171 additions & 0 deletions backend/app/database/videos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Standard library imports
import sqlite3
from typing import Any, List, Mapping, TypedDict, Union

# App-specific imports
from app.config.settings import DATABASE_PATH
from app.logging.setup_logging import get_logger

logger = get_logger(__name__)

# Type aliases
VideoId = str
VideoPath = str
FolderId = Union[int, None]


class VideoRecord(TypedDict):
id: VideoId
path: VideoPath
folder_id: FolderId
thumbnailPath: Union[str, None]
metadata: Union[Mapping[str, Any], str]


def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(DATABASE_PATH)
conn.execute("PRAGMA foreign_keys = ON")
return conn


def db_create_videos_table() -> None:
conn = _connect()
cursor = conn.cursor()

cursor.execute(
"""
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
path VARCHAR UNIQUE,
folder_id INTEGER,
thumbnailPath TEXT,
metadata TEXT,
FOREIGN KEY (folder_id) REFERENCES folders(folder_id) ON DELETE SET NULL
)
"""
)

conn.commit()
conn.close()


def db_insert_video(record: VideoRecord) -> bool:
conn = _connect()
cursor = conn.cursor()

try:
cursor.execute(
"""
INSERT INTO videos (id, path, folder_id, thumbnailPath, metadata)
VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata)
ON CONFLICT(path) DO UPDATE SET
folder_id=excluded.folder_id,
thumbnailPath=excluded.thumbnailPath,
metadata=excluded.metadata
""",
record,
)
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting video record: {e}")
conn.rollback()
return False
finally:
conn.close()


def db_get_all_videos() -> List[dict]:
"""Get all videos from database, filtering out videos whose files no longer exist."""
import os

conn = _connect()
cursor = conn.cursor()

try:
cursor.execute(
"""
SELECT id, path, folder_id, thumbnailPath, metadata
FROM videos
ORDER BY path
"""
)
rows = cursor.fetchall()
videos = []
deleted_ids = []

for v_id, path, folder_id, thumb, metadata in rows:
if not os.path.exists(path):
deleted_ids.append(v_id)
continue

videos.append(
{
"id": v_id,
"path": path,
"folder_id": str(folder_id) if folder_id is not None else "",
"thumbnailPath": thumb,
"metadata": metadata,
}
)
Comment on lines +102 to +110

if deleted_ids:
cursor.execute(
f"DELETE FROM videos WHERE id IN ({','.join('?' * len(deleted_ids))})",
deleted_ids,
)
conn.commit()
logger.info(f"Removed {len(deleted_ids)} deleted video(s) from database")

return videos
except Exception as e:
logger.error(f"Error fetching videos: {e}")
return []
finally:
conn.close()


def db_get_video_by_path(path: str) -> dict | None:
"""Get a video record by its file path."""
conn = _connect()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT id, path, folder_id, thumbnailPath, metadata
FROM videos
WHERE path = ?
""",
(path,),
)
row = cursor.fetchone()
if row:
v_id, path, folder_id, thumb, metadata = row
return {
"id": v_id,
"path": path,
"folder_id": str(folder_id) if folder_id is not None else "",
"thumbnailPath": thumb,
"metadata": metadata,
}
return None
except Exception as e:
logger.error(f"Error fetching video by path {path}: {e}")
return None
finally:
conn.close()


def db_delete_video_by_id(video_id: VideoId) -> bool:
conn = _connect()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM videos WHERE id = ?", (video_id,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error deleting video {video_id}: {e}")
conn.rollback()
return False
finally:
conn.close()
7 changes: 5 additions & 2 deletions backend/app/routes/folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
image_util_process_folder_images,
image_util_process_untagged_images,
)
from app.utils.videos import video_util_process_folder_videos
from app.utils.model_bootstrap import ensure_ai_tagging_models
from app.utils.face_clusters import cluster_util_face_clusters_sync
from app.utils.API import API_util_restart_sync_microservice_watcher
Expand All @@ -68,8 +69,9 @@ def post_folder_add_sequence(folder_path: str, folder_id: int):
folder_data.append((folder_path_from_db, folder_id_from_db, False))

logger.info(f"Add folder: {folder_data}")
# Process images in all folders
# Process images and videos in all folders
image_util_process_folder_images(folder_data)
video_util_process_folder_videos(folder_data)

# Restart sync microservice watcher after processing images
API_util_restart_sync_microservice_watcher()
Expand Down Expand Up @@ -116,8 +118,9 @@ def post_sync_folder_sequence(
folder_data.append((added_folder_path, added_folder_id, False))

logger.info(f"Sync folder: {folder_data}")
# Process images in all folders
# Process images and videos in all folders
image_util_process_folder_images(folder_data)
video_util_process_folder_videos(folder_data)
image_util_process_untagged_images()
cluster_util_face_clusters_sync()

Expand Down
13 changes: 7 additions & 6 deletions backend/app/routes/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,15 @@ def toggle_favourite(req: ToggleFavouriteRequest):
success = db_toggle_image_favourite_status(image_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found or failed to toggle"
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found or failed to toggle",
)
# Fetch updated status to return
image = db_get_image_by_id(image_id)
if not image:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found after toggle"
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found after toggle",
)
return {
"success": True,
Expand All @@ -126,10 +126,11 @@ def toggle_favourite(req: ToggleFavouriteRequest):
except Exception as e:
logger.error(f"error in /toggle-favourite route: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal server error: {e}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal server error: {e}",
)


class ImageInfoResponse(BaseModel):
id: str
path: str
Expand Down
92 changes: 92 additions & 0 deletions backend/app/routes/videos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from fastapi import APIRouter, HTTPException, status
from typing import List
from app.database.videos import db_get_all_videos
from app.schemas.videos import GetAllVideosResponse, ErrorResponse, VideoData
from app.utils.images import image_util_parse_metadata
Comment on lines +4 to +5
from app.utils.videos import video_util_process_folder_videos
from app.database.folders import db_get_all_folder_details

router = APIRouter()


def _map_videos_to_response(videos: List[dict]) -> List[VideoData]:
"""Helper function to map database video dicts to VideoData objects."""
return [
VideoData(
id=v["id"],
path=v["path"],
folder_id=v.get("folder_id"),
thumbnailPath=v["thumbnailPath"],
metadata=image_util_parse_metadata(v.get("metadata")),
)
Comment on lines +12 to +21
for v in videos
]


@router.get(
"/",
response_model=GetAllVideosResponse,
responses={500: {"model": ErrorResponse}},
)
def get_all_videos():
try:
videos = db_get_all_videos()
data = _map_videos_to_response(videos)
return GetAllVideosResponse(
success=True, message=f"Retrieved {len(data)} videos", data=data
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ErrorResponse(
success=False, error="Internal server error", message=str(e)
).model_dump(),
)


@router.post(
"/scan",
response_model=GetAllVideosResponse,
responses={500: {"model": ErrorResponse}},
)
async def scan_videos_from_folders():
try:
rows = db_get_all_folder_details()
folder_data = [(r[1], r[0], False) for r in rows]
video_util_process_folder_videos(folder_data)
videos = db_get_all_videos()
data = _map_videos_to_response(videos)
return GetAllVideosResponse(
success=True, message=f"Scanned {len(folder_data)} folder(s)", data=data
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ErrorResponse(
success=False, error="Internal server error", message=str(e)
).model_dump(),
)


@router.post(
"/cleanup",
response_model=GetAllVideosResponse,
responses={500: {"model": ErrorResponse}},
)
async def cleanup_deleted_videos():
"""Clean up database entries for videos that no longer exist on disk."""
try:
videos = db_get_all_videos()
data = _map_videos_to_response(videos)
return GetAllVideosResponse(
success=True,
message=f"Cleanup complete. {len(data)} video(s) remain.",
data=data,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ErrorResponse(
success=False, error="Internal server error", message=str(e)
).model_dump(),
)
Loading
Loading