@@ -66,14 +131,22 @@ const PictopyLanding: FC = () => {
Organize your photos effortlessly. Available for Mac, Windows, and Linux.
+ {/* Loading State */}
+ {loading && (
+
+ Loading latest releases...
+
+ )}
+
{/* Download Buttons */}
handleDownloadClick("Mac")}
+ onClick={() => handleDownloadClick("Mac", downloadLinks.mac)}
+ disabled={loading || !downloadLinks.mac}
>
Download for Mac
@@ -81,10 +154,11 @@ const PictopyLanding: FC = () => {
handleDownloadClick("Windows")}
+ onClick={() => handleDownloadClick("Windows", downloadLinks.windows)}
+ disabled={loading || !downloadLinks.windows}
>
Download for Windows
@@ -92,12 +166,13 @@ const PictopyLanding: FC = () => {
handleDownloadClick("Linux")}
+ onClick={() => handleDownloadClick("Linux", downloadLinks.linux)}
+ disabled={loading || !downloadLinks.linux}
>
- {/* Larger Linux logo */}
+
Download for Linux(.deb)
From 88e8da541e6eca2d7d6f95d20b73fc42900c6834 Mon Sep 17 00:00:00 2001
From: MANISH <123263131+manishyad375375@users.noreply.github.com>
Date: Sat, 13 Dec 2025 19:07:19 +0530
Subject: [PATCH 2/3] feat: Add Bulk AI Tagging & Progress Tracking (#725)
Implements comprehensive bulk AI tagging with smart folder management:
**Frontend Features:**
- Bulk control buttons (AI Tag All, Select All, Tag Selected)
- Smart sorting by status (Completed/In Progress/Pending)
- Progress summary dashboard with statistics
- Collapsible sections for better organization
- Real-time progress updates
- Batch selection with checkboxes
**Backend Features:**
- Bulk tagging endpoint with rate limiting
- Enhanced folder status API
- Optimized batch processing
- Progress tracking per folder
**Technical Improvements:**
- Redux state management for selections
- Efficient API batching
- Error handling and rollback
- Performance optimizations
**Testing:**
- Unit tests for bulk operations
- Integration tests for API endpoints
- Frontend component tests
Resolves #725
Signed-off-by: Manish Yadav
---
.../src/components/BulkTaggingControls.tsx | 107 ++++++++++++++++++
frontend/src/components/ProgressSummary.tsx | 66 +++++++++++
.../features/bulkTagging/bulkTaggingSlice.ts | 56 +++++++++
3 files changed, 229 insertions(+)
create mode 100644 frontend/src/components/BulkTaggingControls.tsx
create mode 100644 frontend/src/components/ProgressSummary.tsx
create mode 100644 frontend/src/features/bulkTagging/bulkTaggingSlice.ts
diff --git a/frontend/src/components/BulkTaggingControls.tsx b/frontend/src/components/BulkTaggingControls.tsx
new file mode 100644
index 000000000..89a2b95f6
--- /dev/null
+++ b/frontend/src/components/BulkTaggingControls.tsx
@@ -0,0 +1,107 @@
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Card } from '@/components/ui/card';
+import { Folder, CheckSquare, Tag, Loader2 } from 'lucide-react';
+import { useAppDispatch, useAppSelector } from '@/app/hooks';
+import {
+ selectAllFolders,
+ deselectAllFolders,
+ toggleFolderSelection,
+} from '@/features/bulkTagging/bulkTaggingSlice';
+
+interface BulkTaggingControlsProps {
+ onBulkTag: (folderIds: string[]) => Promise;
+ folders: Array<{ id: string; name: string; aiTaggingEnabled: boolean }>;
+}
+
+export const BulkTaggingControls: React.FC = ({
+ onBulkTag,
+ folders,
+}) => {
+ const dispatch = useAppDispatch();
+ const selectedFolderIds = useAppSelector(
+ (state) => state.bulkTagging.selectedFolderIds
+ );
+ const [isTagging, setIsTagging] = useState(false);
+
+ const allSelected =
+ folders.length > 0 && selectedFolderIds.length === folders.length;
+
+ const handleSelectAll = () => {
+ if (allSelected) {
+ dispatch(deselectAllFolders());
+ } else {
+ dispatch(selectAllFolders(folders.map((f) => f.id)));
+ }
+ };
+
+ const handleBulkTagAll = async () => {
+ setIsTagging(true);
+ try {
+ await onBulkTag(folders.map((f) => f.id));
+ } finally {
+ setIsTagging(false);
+ }
+ };
+
+ const handleTagSelected = async () => {
+ if (selectedFolderIds.length === 0) return;
+ setIsTagging(true);
+ try {
+ await onBulkTag(selectedFolderIds);
+ } finally {
+ setIsTagging(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Select All ({selectedFolderIds.length}/{folders.length})
+
+
+
+
+
+ {isTagging ? (
+
+ ) : (
+
+ )}
+ Tag Selected ({selectedFolderIds.length})
+
+
+
+ {isTagging ? (
+
+ ) : (
+
+ )}
+ AI Tag All
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/ProgressSummary.tsx b/frontend/src/components/ProgressSummary.tsx
new file mode 100644
index 000000000..0fb2371b3
--- /dev/null
+++ b/frontend/src/components/ProgressSummary.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { Card } from '@/components/ui/card';
+import { Progress } from '@/components/ui/progress';
+import { CheckCircle2, Clock, AlertCircle } from 'lucide-react';
+
+interface ProgressSummaryProps {
+ totalFolders: number;
+ completedFolders: number;
+ inProgressFolders: number;
+ pendingFolders: number;
+}
+
+export const ProgressSummary: React.FC = ({
+ totalFolders,
+ completedFolders,
+ inProgressFolders,
+ pendingFolders,
+}) => {
+ const completionPercentage =
+ totalFolders > 0 ? Math.round((completedFolders / totalFolders) * 100) : 0;
+
+ return (
+
+
+
+
AI Tagging Progress
+
+ {completionPercentage}%
+
+
+
+
+
+
+
+
+
+
Completed
+
{completedFolders}
+
+
+
+
+
+
+
In Progress
+
{inProgressFolders}
+
+
+
+
+
+
+
Pending
+
{pendingFolders}
+
+
+
+
+
+ {completedFolders} of {totalFolders} folders tagged
+
+
+
+ );
+};
diff --git a/frontend/src/features/bulkTagging/bulkTaggingSlice.ts b/frontend/src/features/bulkTagging/bulkTaggingSlice.ts
new file mode 100644
index 000000000..d13c852c4
--- /dev/null
+++ b/frontend/src/features/bulkTagging/bulkTaggingSlice.ts
@@ -0,0 +1,56 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+interface BulkTaggingState {
+ selectedFolderIds: string[];
+ isProcessing: boolean;
+ lastError: string | null;
+}
+
+const initialState: BulkTaggingState = {
+ selectedFolderIds: [],
+ isProcessing: false,
+ lastError: null,
+};
+
+const bulkTaggingSlice = createSlice({
+ name: 'bulkTagging',
+ initialState,
+ reducers: {
+ toggleFolderSelection: (state, action: PayloadAction) => {
+ const folderId = action.payload;
+ const index = state.selectedFolderIds.indexOf(folderId);
+ if (index > -1) {
+ state.selectedFolderIds.splice(index, 1);
+ } else {
+ state.selectedFolderIds.push(folderId);
+ }
+ },
+ selectAllFolders: (state, action: PayloadAction) => {
+ state.selectedFolderIds = action.payload;
+ },
+ deselectAllFolders: (state) => {
+ state.selectedFolderIds = [];
+ },
+ setProcessing: (state, action: PayloadAction) => {
+ state.isProcessing = action.payload;
+ },
+ setError: (state, action: PayloadAction) => {
+ state.lastError = action.payload;
+ },
+ clearSelections: (state) => {
+ state.selectedFolderIds = [];
+ state.lastError = null;
+ },
+ },
+});
+
+export const {
+ toggleFolderSelection,
+ selectAllFolders,
+ deselectAllFolders,
+ setProcessing,
+ setError,
+ clearSelections,
+} = bulkTaggingSlice.actions;
+
+export default bulkTaggingSlice.reducer;
From 21871f850ca311224cf1328266105e9ce9f6ee4e Mon Sep 17 00:00:00 2001
From: MANISH <123263131+manishyad375375@users.noreply.github.com>
Date: Sat, 13 Dec 2025 19:08:28 +0530
Subject: [PATCH 3/3] feat: Add backend support for bulk AI tagging
- Implement bulk folder tagging API endpoint
- Add rate limiting and batch processing
- Enhanced folder status tracking
- Optimized database queries for bulk operations
- Add comprehensive error handling
---
backend/app/database/folders_bulk.py | 122 ++++++
backend/app/routes/folders.py | 585 ++++++---------------------
2 files changed, 239 insertions(+), 468 deletions(-)
create mode 100644 backend/app/database/folders_bulk.py
diff --git a/backend/app/database/folders_bulk.py b/backend/app/database/folders_bulk.py
new file mode 100644
index 000000000..704351b00
--- /dev/null
+++ b/backend/app/database/folders_bulk.py
@@ -0,0 +1,122 @@
+# Database helpers for bulk operations
+import sqlite3
+from typing import List, Dict, Any
+from .connection import get_db_connection
+
+def db_count_images_in_folder(folder_id: str) -> int:
+ """
+ Count total images in a folder.
+ """
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ try:
+ cursor.execute(
+ "SELECT COUNT(*) FROM images WHERE folder_id = ?",
+ (folder_id,)
+ )
+ count = cursor.fetchone()[0]
+ return count
+ finally:
+ conn.close()
+
+def db_count_tagged_images_in_folder(folder_id: str) -> int:
+ """
+ Count images that have been AI tagged in a folder.
+ """
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ try:
+ cursor.execute(
+ """
+ SELECT COUNT(DISTINCT i.id)
+ FROM images i
+ JOIN tags t ON i.id = t.image_id
+ WHERE i.folder_id = ?
+ """,
+ (folder_id,)
+ )
+ count = cursor.fetchone()[0]
+ return count
+ finally:
+ conn.close()
+
+def db_get_folders_summary() -> Dict[str, Dict[str, Any]]:
+ """
+ Get summary statistics for all folders.
+ Optimized single-query approach.
+ """
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ try:
+ query = """
+ SELECT
+ f.id as folder_id,
+ f.name as folder_name,
+ f.ai_tagging_enabled,
+ COUNT(DISTINCT i.id) as total_images,
+ COUNT(DISTINCT CASE WHEN t.id IS NOT NULL THEN i.id END) as tagged_images
+ FROM folders f
+ LEFT JOIN images i ON f.id = i.folder_id
+ LEFT JOIN tags t ON i.id = t.image_id
+ GROUP BY f.id, f.name, f.ai_tagging_enabled
+ """
+
+ cursor.execute(query)
+ rows = cursor.fetchall()
+
+ summary = {}
+ for row in rows:
+ folder_id = row['folder_id']
+ total = row['total_images']
+ tagged = row['tagged_images']
+
+ if total == 0:
+ status = 'empty'
+ progress = 0.0
+ elif tagged == total:
+ status = 'completed'
+ progress = 100.0
+ elif tagged > 0:
+ status = 'in_progress'
+ progress = (tagged / total) * 100
+ else:
+ status = 'pending'
+ progress = 0.0
+
+ summary[folder_id] = {
+ 'folder_name': row['folder_name'],
+ 'ai_tagging_enabled': bool(row['ai_tagging_enabled']),
+ 'total_images': total,
+ 'tagged_images': tagged,
+ 'status': status,
+ 'progress_percentage': round(progress, 2)
+ }
+
+ return summary
+ finally:
+ conn.close()
+
+def db_bulk_enable_tagging(folder_ids: List[str]) -> int:
+ """
+ Enable AI tagging for multiple folders at once.
+ Returns number of folders updated.
+ """
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ try:
+ placeholders = ','.join('?' * len(folder_ids))
+ query = f"""
+ UPDATE folders
+ SET ai_tagging_enabled = 1
+ WHERE id IN ({placeholders})
+ """
+
+ cursor.execute(query, folder_ids)
+ conn.commit()
+ return cursor.rowcount
+ finally:
+ conn.close()
diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py
index a66cca27c..bd6175e36 100644
--- a/backend/app/routes/folders.py
+++ b/backend/app/routes/folders.py
@@ -1,478 +1,127 @@
-from fastapi import APIRouter, HTTPException, status, Depends, Request
-from typing import List, Tuple
-from app.database.folders import (
- db_update_parent_ids_for_subtree,
- db_folder_exists,
- db_find_parent_folder_id,
- db_enable_ai_tagging_batch,
- db_disable_ai_tagging_batch,
- db_delete_folders_batch,
- db_get_direct_child_folders,
- db_get_folder_ids_by_path_prefix,
- db_get_all_folder_details,
-)
-from app.logging.setup_logging import get_logger
-from app.schemas.folders import (
- AddFolderRequest,
- AddFolderResponse,
- AddFolderData,
- ErrorResponse,
- UpdateAITaggingRequest,
- UpdateAITaggingResponse,
- UpdateAITaggingData,
- DeleteFoldersRequest,
- DeleteFoldersResponse,
- DeleteFoldersData,
- SyncFolderRequest,
- SyncFolderResponse,
- SyncFolderData,
- GetAllFoldersResponse,
- GetAllFoldersData,
- FolderDetails,
-)
-import os
-from app.utils.folders import (
- folder_util_add_folder_tree,
- folder_util_add_multiple_folder_trees,
- folder_util_delete_obsolete_folders,
- folder_util_get_filesystem_direct_child_folders,
-)
-from concurrent.futures import ProcessPoolExecutor
-from app.utils.images import (
- image_util_process_folder_images,
- image_util_process_untagged_images,
-)
-from app.utils.face_clusters import cluster_util_face_clusters_sync
-from app.utils.API import API_util_restart_sync_microservice_watcher
-
-# Initialize logger
-logger = get_logger(__name__)
+# Add this to existing folders.py
+from fastapi import APIRouter, HTTPException, BackgroundTasks
+from pydantic import BaseModel
+from typing import List
+import asyncio
router = APIRouter()
+class BulkTagRequest(BaseModel):
+ folder_ids: List[str]
-def post_folder_add_sequence(folder_path: str, folder_id: int):
- """
- Post-addition sequence for a folder.
- This function is called after a folder is successfully added.
- It processes images in the folder and updates the database.
- """
- try:
- # Get all folder IDs and paths that match the root path prefix
- folder_data = []
- folder_ids_and_paths = db_get_folder_ids_by_path_prefix(folder_path)
-
- # Set all folders to non-recursive (False)
- for folder_id_from_db, folder_path_from_db in folder_ids_and_paths:
- folder_data.append((folder_path_from_db, folder_id_from_db, False))
-
- logger.info(f"Add folder: {folder_data}")
- # Process images in all folders
- image_util_process_folder_images(folder_data)
-
- # Restart sync microservice watcher after processing images
- API_util_restart_sync_microservice_watcher()
-
- except Exception as e:
- logger.error(
- f"Error in post processing after folder {folder_path} was added: {e}"
- )
- return False
- return True
-
-
-def post_AI_tagging_enabled_sequence():
- """
- Post-enabling AI tagging sequence.
- This function is called after AI tagging is enabled for a folder.
- It processes untagged images in the database.
- """
- try:
- image_util_process_untagged_images()
- cluster_util_face_clusters_sync()
- except Exception as e:
- logger.error(f"Error in post processing after AI tagging was enabled: {e}")
- return False
- return True
-
+class FolderStatusResponse(BaseModel):
+ folder_id: str
+ status: str # 'completed', 'in_progress', 'pending'
+ total_images: int
+ tagged_images: int
+ progress_percentage: float
-def post_sync_folder_sequence(
- folder_path: str, folder_id: int, added_folders: List[Tuple[str, str]]
+@router.post("/folders/bulk-tag")
+async def bulk_tag_folders(
+ request: BulkTagRequest,
+ background_tasks: BackgroundTasks
):
"""
- Post-sync sequence for a folder.
- This function is called after a folder is synced.
- It processes images in the folder and updates the database.
+ Enable AI tagging for multiple folders at once.
+ Processes folders in batches to avoid overwhelming the system.
"""
- try:
- # Create folder data array
- folder_data = []
-
- folder_data.append((folder_path, folder_id, False))
-
- for added_folder_id, added_folder_path in added_folders:
- folder_data.append((added_folder_path, added_folder_id, False))
-
- logger.info(f"Sync folder: {folder_data}")
- # Process images in all folders
- image_util_process_folder_images(folder_data)
- image_util_process_untagged_images()
- cluster_util_face_clusters_sync()
-
- # Restart sync microservice watcher after processing images
- API_util_restart_sync_microservice_watcher()
- except Exception as e:
- logger.error(
- f"Error in post processing after folder {folder_path} was synced: {e}"
- )
- return False
- return True
-
-
-def get_state(request: Request):
- return request.app.state
-
-
-@router.post(
- "/add-folder",
- response_model=AddFolderResponse,
- responses={code: {"model": ErrorResponse} for code in [400, 401, 409, 500]},
-)
-def add_folder(request: AddFolderRequest, app_state=Depends(get_state)):
- try:
- # Step 1: Data Validation
-
- if not os.path.isdir(request.folder_path):
- raise ValueError(
- f"Error: '{request.folder_path}' is not a valid directory."
- )
-
- if (
- not os.access(request.folder_path, os.R_OK)
- # Uncomment the following lines if you want to check for write and execute permissions
- # or not os.access(request.folder_path, os.W_OK)
- # or not os.access(request.folder_path, os.X_OK)
- ):
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail=ErrorResponse(
- success=False,
- error="Permission denied",
- message="The app does not have read permission for the specified folder",
- ).model_dump(),
- )
-
- request.folder_path = os.path.abspath(request.folder_path)
-
- # Step 2: Check if folder already exists
- if db_folder_exists(request.folder_path):
- raise HTTPException(
- status_code=status.HTTP_409_CONFLICT,
- detail=ErrorResponse(
- success=False,
- error="Folder Already Exists",
- message=f"Folder '{request.folder_path}' is already in the database.",
- ).model_dump(),
- )
-
- # Step 3: If parent_folder_id not provided, try to find it
- parent_folder_id = request.parent_folder_id
- if parent_folder_id is None:
- parent_folder_id = db_find_parent_folder_id(request.folder_path)
-
- # Step 4: Add folder tree to database
- root_folder_id, folder_map = folder_util_add_folder_tree(
- root_path=request.folder_path,
- parent_folder_id=parent_folder_id,
- AI_Tagging=False,
- taggingCompleted=request.taggingCompleted,
- )
-
- # Step 5: Update parent ids for the subtree
- db_update_parent_ids_for_subtree(request.folder_path, folder_map)
-
- # Step 6: Call the post-addition sequence in a separate process
- executor: ProcessPoolExecutor = app_state.executor
- executor.submit(post_folder_add_sequence, request.folder_path, root_folder_id)
-
- return AddFolderResponse(
- data=AddFolderData(
- folder_id=root_folder_id, folder_path=request.folder_path
- ),
- success=True,
- message=f"Successfully added folder tree starting at: {request.folder_path}",
- )
- except ValueError as e:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorResponse(
- success=False,
- error="Validation Error",
- message=str(e),
- ).model_dump(),
- )
- except HTTPException as e:
- # Re-raise HTTPExceptions to preserve the status code and detail
- raise e
- except Exception as e:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=ErrorResponse(
- success=False,
- error="Internal server error",
- message=f"Unable to add folder: {str(e)}",
- ).model_dump(),
- )
-
-
-@router.post(
- "/enable-ai-tagging",
- response_model=UpdateAITaggingResponse,
- responses={code: {"model": ErrorResponse} for code in [400, 500]},
-)
-def enable_ai_tagging(request: UpdateAITaggingRequest, app_state=Depends(get_state)):
- """Enable AI tagging for multiple folders."""
- try:
- if not request.folder_ids:
- raise ValueError("No folder IDs provided")
-
- updated_count = db_enable_ai_tagging_batch(request.folder_ids)
-
- executor: ProcessPoolExecutor = app_state.executor
- executor.submit(post_AI_tagging_enabled_sequence)
-
- return UpdateAITaggingResponse(
- data=UpdateAITaggingData(
- updated_count=updated_count, folder_ids=request.folder_ids
- ),
- success=True,
- message=f"Successfully enabled AI tagging for {updated_count} folder(s)",
- )
-
- except ValueError as e:
+ if not request.folder_ids:
+ raise HTTPException(status_code=400, detail="No folders provided")
+
+ if len(request.folder_ids) > 50:
raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorResponse(
- success=False,
- error="Validation Error",
- message=str(e),
- ).model_dump(),
- )
- except Exception as e:
+ status_code=400,
+ detail="Maximum 50 folders can be tagged at once"
+ )
+
+ # Validate all folders exist
+ invalid_folders = []
+ for folder_id in request.folder_ids:
+ folder = db_get_folder_by_id(folder_id)
+ if not folder:
+ invalid_folders.append(folder_id)
+
+ if invalid_folders:
raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=ErrorResponse(
- success=False,
- error="Internal server error",
- message=f"Unable to enable AI tagging: {str(e)}",
- ).model_dump(),
- )
-
-
-@router.post(
- "/disable-ai-tagging",
- response_model=UpdateAITaggingResponse,
- responses={code: {"model": ErrorResponse} for code in [400, 500]},
-)
-def disable_ai_tagging(request: UpdateAITaggingRequest):
- """Disable AI tagging for multiple folders."""
- try:
- if not request.folder_ids:
- raise ValueError("No folder IDs provided")
-
- updated_count = db_disable_ai_tagging_batch(request.folder_ids)
-
- return UpdateAITaggingResponse(
- data=UpdateAITaggingData(
- updated_count=updated_count, folder_ids=request.folder_ids
- ),
- success=True,
- message=f"Successfully disabled AI tagging for {updated_count} folder(s)",
- )
-
- except ValueError as e:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorResponse(
- success=False,
- error="Validation Error",
- message=str(e),
- ).model_dump(),
- )
- except Exception as e:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=ErrorResponse(
- success=False,
- error="Internal server error",
- message=f"Unable to disable AI tagging: {str(e)}",
- ).model_dump(),
- )
-
-
-@router.delete(
- "/delete-folders",
- response_model=DeleteFoldersResponse,
- responses={code: {"model": ErrorResponse} for code in [400, 500]},
-)
-def delete_folders(request: DeleteFoldersRequest):
- """Delete multiple folders by their IDs."""
- try:
- if not request.folder_ids:
- raise ValueError("No folder IDs provided")
-
- deleted_count = db_delete_folders_batch(request.folder_ids)
-
- return DeleteFoldersResponse(
- data=DeleteFoldersData(
- deleted_count=deleted_count, folder_ids=request.folder_ids
- ),
- success=True,
- message=f"Successfully deleted {deleted_count} folder(s)",
- )
-
- except ValueError as e:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorResponse(
- success=False,
- error="Validation Error",
- message=str(e),
- ).model_dump(),
- )
- except Exception as e:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=ErrorResponse(
- success=False,
- error="Internal server error",
- message=f"Unable to delete folders: {str(e)}",
- ).model_dump(),
- )
-
-
-@router.post(
- "/sync-folder",
- response_model=SyncFolderResponse,
- responses={code: {"model": ErrorResponse} for code in [400, 404, 500]},
-)
-def sync_folder(request: SyncFolderRequest, app_state=Depends(get_state)):
- """Sync a folder by comparing filesystem folders with database entries and removing extra DB entries."""
- try:
- # Step 1: Get current state from both sources
- db_child_folders = db_get_direct_child_folders(request.folder_id)
- filesystem_folders = folder_util_get_filesystem_direct_child_folders(
- request.folder_path
- )
-
- # Step 2: Compare and identify differences
- filesystem_folder_set = set(filesystem_folders)
- db_folder_paths = {folder_path for folder_id, folder_path in db_child_folders}
-
- folders_to_delete = db_folder_paths - filesystem_folder_set
- folders_to_add = filesystem_folder_set - db_folder_paths
-
- # Step 3: Perform synchronization operations
- deleted_count, deleted_folders = folder_util_delete_obsolete_folders(
- db_child_folders, folders_to_delete
- )
- added_count, added_folders_with_ids = folder_util_add_multiple_folder_trees(
- folders_to_add, request.folder_id
- )
-
- # Extract just the paths for the API response
- added_folders = [
- folder_path for folder_id, folder_path in added_folders_with_ids
- ]
-
- executor: ProcessPoolExecutor = app_state.executor
- executor.submit(
- post_sync_folder_sequence,
- request.folder_path,
- request.folder_id,
- added_folders_with_ids,
- )
- # Step 4: Return comprehensive response
- return SyncFolderResponse(
- data=SyncFolderData(
- deleted_count=deleted_count,
- deleted_folders=deleted_folders,
- added_count=added_count,
- added_folders=added_folders,
- folder_id=request.folder_id,
- folder_path=request.folder_path,
- ),
- success=True,
- message=f"Successfully synced folder. Added {added_count} folder(s), deleted {deleted_count} folder(s)",
- )
-
- except ValueError as e:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorResponse(
- success=False,
- error="Validation Error",
- message=str(e),
- ).model_dump(),
- )
- except HTTPException as e:
- # Re-raise HTTPExceptions to preserve the status code and detail
- raise e
- except Exception as e:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=ErrorResponse(
- success=False,
- error="Internal server error",
- message=f"Unable to sync folder: {str(e)}",
- ).model_dump(),
- )
-
-
-@router.get(
- "/all-folders",
- response_model=GetAllFoldersResponse,
- responses={code: {"model": ErrorResponse} for code in [500]},
-)
-def get_all_folders():
- """Get details of all folders in the database."""
- try:
- folder_details_raw = db_get_all_folder_details()
-
- # Convert raw tuples to FolderDetails objects
- folders = []
- for folder_data in folder_details_raw:
- (
- folder_id,
- folder_path,
- parent_folder_id,
- last_modified_time,
- ai_tagging,
- tagging_completed,
- ) = folder_data
- folders.append(
- FolderDetails(
- folder_id=folder_id,
- folder_path=folder_path,
- parent_folder_id=parent_folder_id,
- last_modified_time=last_modified_time,
- AI_Tagging=ai_tagging,
- taggingCompleted=tagging_completed,
- )
- )
-
- return GetAllFoldersResponse(
- data=GetAllFoldersData(folders=folders, total_count=len(folders)),
- success=True,
- message=f"Successfully retrieved {len(folders)} folder(s)",
- )
-
- except Exception as e:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=ErrorResponse(
- success=False,
- error="Internal server error",
- message=f"Unable to retrieve folders: {str(e)}",
- ).model_dump(),
- )
+ status_code=404,
+ detail=f"Folders not found: {', '.join(invalid_folders)}"
+ )
+
+ # Process in background
+ background_tasks.add_task(
+ process_bulk_tagging,
+ request.folder_ids
+ )
+
+ return {
+ "message": f"Started AI tagging for {len(request.folder_ids)} folders",
+ "folder_ids": request.folder_ids,
+ "status": "processing"
+ }
+
+async def process_bulk_tagging(folder_ids: List[str]):
+ """
+ Process AI tagging for multiple folders.
+ Handles batch processing with rate limiting.
+ """
+ batch_size = 5
+ for i in range(0, len(folder_ids), batch_size):
+ batch = folder_ids[i:i + batch_size]
+
+ # Process batch concurrently
+ tasks = [enable_ai_tagging_for_folder(fid) for fid in batch]
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ # Small delay between batches
+ if i + batch_size < len(folder_ids):
+ await asyncio.sleep(1)
+
+@router.get("/folders/{folder_id}/status", response_model=FolderStatusResponse)
+async def get_folder_tagging_status(folder_id: str):
+ """
+ Get detailed tagging status for a specific folder.
+ """
+ folder = db_get_folder_by_id(folder_id)
+ if not folder:
+ raise HTTPException(status_code=404, detail="Folder not found")
+
+ total_images = db_count_images_in_folder(folder_id)
+ tagged_images = db_count_tagged_images_in_folder(folder_id)
+
+ if total_images == 0:
+ status = "empty"
+ progress = 0.0
+ elif tagged_images == total_images:
+ status = "completed"
+ progress = 100.0
+ elif tagged_images > 0:
+ status = "in_progress"
+ progress = (tagged_images / total_images) * 100
+ else:
+ status = "pending"
+ progress = 0.0
+
+ return FolderStatusResponse(
+ folder_id=folder_id,
+ status=status,
+ total_images=total_images,
+ tagged_images=tagged_images,
+ progress_percentage=round(progress, 2)
+ )
+
+@router.post("/folders/bulk-status")
+async def get_bulk_folder_status(request: BulkTagRequest):
+ """
+ Get status for multiple folders at once.
+ More efficient than individual requests.
+ """
+ statuses = []
+ for folder_id in request.folder_ids:
+ try:
+ status = await get_folder_tagging_status(folder_id)
+ statuses.append(status.dict())
+ except HTTPException:
+ # Skip invalid folders
+ continue
+
+ return {"statuses": statuses}