Skip to content

Commit 225c8f8

Browse files
authored
Merge pull request #21 from Azure-Samples/feat-gallery-optimization
Add selection toggle button to gallery adding multi-selection of images or videos to delete or move in batch
2 parents 3ad402f + 251156b commit 225c8f8

File tree

10 files changed

+1298
-213
lines changed

10 files changed

+1298
-213
lines changed

backend/api/endpoints/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Import all endpoint routers here
2+
from . import images
3+
from . import videos
4+
from . import gallery
5+
from . import env
6+
from . import batch

backend/api/endpoints/batch.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
from fastapi import APIRouter, HTTPException, Depends, Body, BackgroundTasks
2+
from typing import Dict, List, Optional, Any
3+
import asyncio
4+
5+
from backend.core.azure_storage import AzureBlobStorageService
6+
from backend.core.config import settings
7+
from backend.models.gallery import (
8+
MediaType,
9+
AssetDeleteResponse,
10+
BatchDeleteRequest,
11+
BatchDeleteResponse,
12+
BatchMoveRequest,
13+
BatchMoveResponse
14+
)
15+
16+
router = APIRouter()
17+
18+
19+
@router.post("/delete", response_model=BatchDeleteResponse)
20+
async def delete_multiple_assets(
21+
request: BatchDeleteRequest,
22+
background_tasks: BackgroundTasks,
23+
azure_storage_service: AzureBlobStorageService = Depends(
24+
lambda: AzureBlobStorageService())
25+
):
26+
"""
27+
Delete multiple assets from Azure Blob Storage
28+
29+
This endpoint deletes multiple assets in a single request.
30+
For large batches, it uses background tasks to prevent timeouts.
31+
"""
32+
try:
33+
# Validate request
34+
if not request.blob_names:
35+
raise HTTPException(
36+
status_code=400, detail="No blob names provided")
37+
38+
# Determine container name
39+
container_name = request.container
40+
if not container_name:
41+
if not request.media_type:
42+
raise HTTPException(
43+
status_code=400,
44+
detail="Either media_type or container must be specified"
45+
)
46+
container_name = settings.AZURE_BLOB_IMAGE_CONTAINER if request.media_type == MediaType.IMAGE else settings.AZURE_BLOB_VIDEO_CONTAINER
47+
48+
# Track results
49+
results = {}
50+
51+
# Use background task for large batches
52+
use_background = len(request.blob_names) > 10
53+
54+
if use_background:
55+
# Process deletions in background
56+
background_tasks.add_task(
57+
_delete_assets_background,
58+
request.blob_names,
59+
container_name,
60+
azure_storage_service
61+
)
62+
63+
return BatchDeleteResponse(
64+
success=True,
65+
message=f"Deletion of {len(request.blob_names)} assets started in background",
66+
total=len(request.blob_names),
67+
results={},
68+
background_task=True
69+
)
70+
else:
71+
# Process deletions synchronously
72+
for blob_name in request.blob_names:
73+
success = azure_storage_service.delete_asset(blob_name, container_name)
74+
results[blob_name] = success
75+
76+
# Count successes and failures
77+
success_count = sum(1 for success in results.values() if success)
78+
failure_count = len(request.blob_names) - success_count
79+
80+
return BatchDeleteResponse(
81+
success=failure_count == 0,
82+
message=f"Deleted {success_count} assets successfully" + (f", {failure_count} failed" if failure_count > 0 else ""),
83+
total=len(request.blob_names),
84+
results=results,
85+
background_task=False
86+
)
87+
except Exception as e:
88+
raise HTTPException(status_code=500, detail=str(e))
89+
90+
91+
async def _delete_assets_background(
92+
blob_names: List[str],
93+
container_name: str,
94+
azure_storage_service: AzureBlobStorageService
95+
):
96+
"""Background task to delete multiple assets"""
97+
try:
98+
results = {}
99+
for blob_name in blob_names:
100+
try:
101+
success = azure_storage_service.delete_asset(blob_name, container_name)
102+
results[blob_name] = success
103+
except Exception as e:
104+
print(f"Error deleting asset {blob_name}: {str(e)}")
105+
results[blob_name] = False
106+
107+
# Count successes and failures
108+
success_count = sum(1 for success in results.values() if success)
109+
failure_count = len(blob_names) - success_count
110+
111+
print(f"Background batch deletion complete: {success_count} succeeded, {failure_count} failed")
112+
except Exception as e:
113+
print(f"Error in background batch deletion: {str(e)}")
114+
115+
116+
@router.post("/move", response_model=BatchMoveResponse)
117+
async def move_multiple_assets(
118+
request: BatchMoveRequest,
119+
background_tasks: BackgroundTasks,
120+
azure_storage_service: AzureBlobStorageService = Depends(
121+
lambda: AzureBlobStorageService())
122+
):
123+
"""
124+
Move multiple assets to a different folder
125+
126+
This endpoint moves multiple assets to a target folder in a single request.
127+
For large batches, it uses background tasks to prevent timeouts.
128+
"""
129+
try:
130+
# Validate request
131+
if not request.blob_names:
132+
raise HTTPException(
133+
status_code=400, detail="No blob names provided")
134+
135+
# Normalize target folder
136+
normalized_folder = azure_storage_service.normalize_folder_path(
137+
request.target_folder)
138+
139+
# Determine container name
140+
container_name = request.container
141+
if not container_name:
142+
if not request.media_type:
143+
raise HTTPException(
144+
status_code=400,
145+
detail="Either media_type or container must be specified"
146+
)
147+
container_name = settings.AZURE_BLOB_IMAGE_CONTAINER if request.media_type == MediaType.IMAGE else settings.AZURE_BLOB_VIDEO_CONTAINER
148+
149+
# Check if target folder exists
150+
container_client = azure_storage_service.blob_service_client.get_container_client(
151+
container_name)
152+
folders = azure_storage_service.list_folders(container_name)
153+
if normalized_folder not in folders and normalized_folder != "":
154+
# Create marker for folder
155+
folder_marker = f"{normalized_folder}.folder"
156+
marker_metadata = {
157+
"is_folder_marker": "true",
158+
"folder_path": normalized_folder
159+
}
160+
folder_blob_client = container_client.get_blob_client(
161+
folder_marker)
162+
folder_blob_client.upload_blob(
163+
data=b"", overwrite=True, metadata=marker_metadata)
164+
165+
# Track results
166+
results = {}
167+
168+
# Use background task for large batches or if there are large files
169+
use_background = len(request.blob_names) > 5
170+
171+
if use_background:
172+
# Process moves in background
173+
background_tasks.add_task(
174+
_move_assets_background,
175+
request.blob_names,
176+
container_name,
177+
normalized_folder,
178+
azure_storage_service
179+
)
180+
181+
return BatchMoveResponse(
182+
success=True,
183+
message=f"Moving {len(request.blob_names)} assets to {normalized_folder or 'root'} started in background",
184+
total=len(request.blob_names),
185+
results={},
186+
target_folder=normalized_folder,
187+
background_task=True
188+
)
189+
else:
190+
# Process moves synchronously
191+
for blob_name in request.blob_names:
192+
try:
193+
# Get original blob content and metadata
194+
content, content_type = azure_storage_service.get_asset_content(
195+
blob_name, container_name)
196+
if not content:
197+
results[blob_name] = False
198+
continue
199+
200+
# Get metadata
201+
metadata = azure_storage_service.get_asset_metadata(
202+
blob_name, container_name) or {}
203+
204+
# Create new blob name with target folder
205+
file_name = blob_name.split('/')[-1] if '/' in blob_name else blob_name
206+
new_blob_name = f"{normalized_folder}{file_name}"
207+
208+
# Create blob client for new location
209+
blob_client = container_client.get_blob_client(new_blob_name)
210+
211+
# Update metadata with new folder path
212+
metadata['folder_path'] = normalized_folder
213+
214+
# Set content type
215+
from azure.storage.blob import ContentSettings
216+
content_settings = ContentSettings(content_type=content_type)
217+
218+
# Upload to new location
219+
blob_client.upload_blob(data=content, overwrite=True,
220+
metadata=metadata, content_settings=content_settings)
221+
222+
# Delete original blob after successful copy
223+
azure_storage_service.delete_asset(blob_name, container_name)
224+
225+
results[blob_name] = True
226+
except Exception as e:
227+
print(f"Error moving asset {blob_name}: {str(e)}")
228+
results[blob_name] = False
229+
230+
# Count successes and failures
231+
success_count = sum(1 for success in results.values() if success)
232+
failure_count = len(request.blob_names) - success_count
233+
234+
return BatchMoveResponse(
235+
success=failure_count == 0,
236+
message=f"Moved {success_count} assets to {normalized_folder or 'root'} successfully" + (f", {failure_count} failed" if failure_count > 0 else ""),
237+
total=len(request.blob_names),
238+
results=results,
239+
target_folder=normalized_folder,
240+
background_task=False
241+
)
242+
except Exception as e:
243+
raise HTTPException(status_code=500, detail=str(e))
244+
245+
246+
async def _move_assets_background(
247+
blob_names: List[str],
248+
container_name: str,
249+
target_folder: str,
250+
azure_storage_service: AzureBlobStorageService
251+
):
252+
"""Background task to move multiple assets to a different folder"""
253+
try:
254+
container_client = azure_storage_service.blob_service_client.get_container_client(
255+
container_name)
256+
results = {}
257+
258+
for blob_name in blob_names:
259+
try:
260+
# Get original blob content and metadata
261+
content, content_type = azure_storage_service.get_asset_content(
262+
blob_name, container_name)
263+
if not content:
264+
print(f"Error: Asset content not found: {blob_name}")
265+
results[blob_name] = False
266+
continue
267+
268+
# Get metadata
269+
metadata = azure_storage_service.get_asset_metadata(
270+
blob_name, container_name) or {}
271+
272+
# Create new blob name with target folder
273+
file_name = blob_name.split('/')[-1] if '/' in blob_name else blob_name
274+
new_blob_name = f"{target_folder}{file_name}"
275+
276+
# Create blob client for new location
277+
blob_client = container_client.get_blob_client(new_blob_name)
278+
279+
# Update metadata with new folder path
280+
metadata['folder_path'] = target_folder
281+
282+
# Set content type
283+
from azure.storage.blob import ContentSettings
284+
content_settings = ContentSettings(content_type=content_type)
285+
286+
# Upload to new location
287+
blob_client.upload_blob(data=content, overwrite=True,
288+
metadata=metadata, content_settings=content_settings)
289+
290+
# Delete original blob after successful copy
291+
azure_storage_service.delete_asset(blob_name, container_name)
292+
293+
results[blob_name] = True
294+
print(f"Successfully moved asset from {blob_name} to {new_blob_name}")
295+
except Exception as e:
296+
print(f"Error moving asset {blob_name} in background: {str(e)}")
297+
results[blob_name] = False
298+
299+
# Count successes and failures
300+
success_count = sum(1 for success in results.values() if success)
301+
failure_count = len(blob_names) - success_count
302+
303+
print(f"Background batch move complete: {success_count} succeeded, {failure_count} failed")
304+
except Exception as e:
305+
print(f"Error in background batch move: {str(e)}")

backend/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66

77
from .core.config import settings
8-
from .api.endpoints import images, videos, gallery, env
8+
from .api.endpoints import images, videos, gallery, env, batch
99

1010
# Configure logging to suppress Azure Blob Storage verbose logs
1111
logging.getLogger('azure.core.pipeline.policies.http_logging_policy').setLevel(
@@ -41,6 +41,8 @@
4141
app.include_router(
4242
gallery.router, prefix=f"{settings.API_V1_STR}/gallery", tags=["gallery"])
4343
app.include_router(env.router, prefix=f"{settings.API_V1_STR}", tags=["env"])
44+
app.include_router(
45+
batch.router, prefix=f"{settings.API_V1_STR}/batch", tags=["batch"])
4446
# app.include_router(organizer.router, prefix=f"{settings.API_V1_STR}/organizer", tags=["organizer"])
4547
# app.include_router(sora.router, prefix=f"{settings.API_V1_STR}/sora", tags=["sora"])
4648

backend/models/gallery.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pydantic import BaseModel, Field
2-
from typing import List, Optional, Dict, Any
2+
from typing import List, Optional, Dict, Any, Union
33
from datetime import datetime
44
from enum import Enum
55

@@ -103,3 +103,33 @@ class SasTokenResponse(BaseModel):
103103
video_container_url: str
104104
image_container_url: str
105105
expiry: datetime
106+
107+
108+
class BatchDeleteRequest(BaseModel):
109+
"""Request model for batch deletion of assets"""
110+
blob_names: List[str] = Field(..., description="List of blob names to delete")
111+
media_type: Optional[MediaType] = Field(None, description="Type of media (image or video) to determine container")
112+
container: Optional[str] = Field(None, description="Container name (images or videos) - overrides media_type if provided")
113+
114+
115+
class BatchDeleteResponse(BaseResponse):
116+
"""Response model for batch deletion operations"""
117+
total: int = Field(..., description="Total number of assets in the batch")
118+
results: Dict[str, bool] = Field(..., description="Results of deletion operations by blob name")
119+
background_task: bool = Field(False, description="Whether the operation was performed as a background task")
120+
121+
122+
class BatchMoveRequest(BaseModel):
123+
"""Request model for batch move of assets"""
124+
blob_names: List[str] = Field(..., description="List of blob names to move")
125+
target_folder: str = Field(..., description="Target folder to move assets to")
126+
media_type: Optional[MediaType] = Field(None, description="Type of media (image or video) to determine container")
127+
container: Optional[str] = Field(None, description="Container name (images or videos) - overrides media_type if provided")
128+
129+
130+
class BatchMoveResponse(BaseResponse):
131+
"""Response model for batch move operations"""
132+
total: int = Field(..., description="Total number of assets in the batch")
133+
results: Dict[str, bool] = Field(..., description="Results of move operations by blob name")
134+
target_folder: str = Field(..., description="Target folder the assets were moved to")
135+
background_task: bool = Field(False, description="Whether the operation was performed as a background task")

0 commit comments

Comments
 (0)