Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@ -23,3 +23,7 @@
DATABASE_PATH = "app/database/PictoPy.db"
THUMBNAIL_IMAGES_PATH = "./images/thumbnails"
IMAGES_PATH = "./images"

# Videos storage
VIDEOS_PATH = "./videos"
THUMBNAIL_VIDEOS_PATH = "./videos/thumbnails"
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.face_clusters import cluster_util_face_clusters_sync
from app.utils.API import API_util_restart_sync_microservice_watcher

Expand All @@ -67,8 +68,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 @@ -114,8 +116,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
105 changes: 105 additions & 0 deletions backend/app/routes/videos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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()


@router.get(
"/",
response_model=GetAllVideosResponse,
responses={500: {"model": ErrorResponse}},
)
def get_all_videos():
try:
videos = db_get_all_videos()
data: List[VideoData] = [
VideoData(
id=v["id"],
path=v["path"],
folder_id=v.get("folder_id"),
thumbnailPath=v["thumbnailPath"],
metadata=image_util_parse_metadata(v.get("metadata")),
)
for v in videos
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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: List[VideoData] = [
VideoData(
id=v["id"],
path=v["path"],
folder_id=v.get("folder_id"),
thumbnailPath=v["thumbnailPath"],
metadata=image_util_parse_metadata(v.get("metadata")),
)
for v in 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: List[VideoData] = [
VideoData(
id=v["id"],
path=v["path"],
folder_id=v.get("folder_id"),
thumbnailPath=v["thumbnailPath"],
metadata=image_util_parse_metadata(v.get("metadata")),
)
for v in 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(),
)
33 changes: 33 additions & 0 deletions backend/app/schemas/videos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from pydantic import BaseModel
from typing import Optional, List, Mapping, Any


class VideoMetadata(BaseModel):
name: str
date_created: Optional[str] = None
width: int
height: int
duration: float
file_location: str
file_size: int
item_type: str


class VideoData(BaseModel):
id: str
path: str
folder_id: Optional[str] = None
thumbnailPath: Optional[str] = None
metadata: Mapping[str, Any] | VideoMetadata


class GetAllVideosResponse(BaseModel):
success: bool
message: str
data: List[VideoData]


class ErrorResponse(BaseModel):
success: bool = False
message: str
error: str
Loading