feat: add PFP album/gallery system with batch upload, cropping, and disk management
- Backend: album storage in memory/profile_pictures/album/{uuid}/ with
original.png, cropped.png, and metadata.json per entry
- add_to_album/add_batch_to_album with efficient resource management
(vision model + face detector kept alive across batch)
- set_album_entry_as_current auto-archives current PFP before replacing
- manual/auto crop album entries without applying to Discord
- Disk usage tracking, single & bulk delete
- API: full CRUD endpoints under /profile-picture/album/*
- Frontend: collapsible album grid in tab11 with thumbnail cards,
multi-select checkboxes for bulk delete, detail panel with crop
interface (Cropper.js), description editor, set-as-current action
This commit is contained in:
227
bot/api.py
227
bot/api.py
@@ -1289,6 +1289,233 @@ async def get_profile_picture_description():
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
# === Profile Picture Album / Gallery ===
|
||||
|
||||
@app.get("/profile-picture/album")
|
||||
async def list_album_entries():
|
||||
"""List all album entries (newest first)"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
entries = profile_picture_manager.get_album_entries()
|
||||
return {"status": "ok", "entries": entries, "count": len(entries)}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.get("/profile-picture/album/disk-usage")
|
||||
async def get_album_disk_usage():
|
||||
"""Get album disk usage statistics"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
usage = profile_picture_manager.get_album_disk_usage()
|
||||
return {"status": "ok", **usage}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.get("/profile-picture/album/{entry_id}")
|
||||
async def get_album_entry(entry_id: str):
|
||||
"""Get metadata for a single album entry"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
meta = profile_picture_manager.get_album_entry(entry_id)
|
||||
if meta:
|
||||
return {"status": "ok", "entry": meta}
|
||||
else:
|
||||
return {"status": "error", "message": "Album entry not found"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.get("/profile-picture/album/{entry_id}/image/{image_type}")
|
||||
async def serve_album_image(entry_id: str, image_type: str):
|
||||
"""Serve an album entry's image (original or cropped)"""
|
||||
if image_type not in ("original", "cropped"):
|
||||
return {"status": "error", "message": "image_type must be 'original' or 'cropped'"}
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
path = profile_picture_manager.get_album_image_path(entry_id, image_type)
|
||||
if path:
|
||||
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
|
||||
else:
|
||||
return {"status": "error", "message": f"No {image_type} image for this entry"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.post("/profile-picture/album/add")
|
||||
async def add_to_album(file: UploadFile = File(...)):
|
||||
"""Add a single image to the album"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
image_bytes = await file.read()
|
||||
logger.info(f"Adding image to album ({len(image_bytes)} bytes)")
|
||||
result = await profile_picture_manager.add_to_album(
|
||||
image_bytes=image_bytes,
|
||||
source="custom_upload",
|
||||
debug=True
|
||||
)
|
||||
if result["success"]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Image added to album",
|
||||
"entry_id": result["entry_id"],
|
||||
"metadata": result.get("metadata", {})
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding to album: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.post("/profile-picture/album/add-batch")
|
||||
async def add_batch_to_album(files: List[UploadFile] = File(...)):
|
||||
"""Batch-add multiple images to the album efficiently"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
images = []
|
||||
for f in files:
|
||||
data = await f.read()
|
||||
images.append({"bytes": data, "source": "custom_upload"})
|
||||
logger.info(f"Batch adding {len(images)} images to album")
|
||||
result = await profile_picture_manager.add_batch_to_album(images=images, debug=True)
|
||||
return {
|
||||
"status": "ok" if result["success"] else "partial",
|
||||
"message": f"Added {result['succeeded']}/{result['total']} images",
|
||||
"succeeded": result["succeeded"],
|
||||
"failed": result["failed"],
|
||||
"total": result["total"],
|
||||
"results": [
|
||||
{"entry_id": r.get("entry_id"), "success": r["success"], "error": r.get("error")}
|
||||
for r in result["results"]
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in batch album add: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.post("/profile-picture/album/{entry_id}/set-current")
|
||||
async def set_album_entry_as_current(entry_id: str):
|
||||
"""Set an album entry as the current Discord profile picture"""
|
||||
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||
return {"status": "error", "message": "Bot not ready"}
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
result = await profile_picture_manager.set_album_entry_as_current(
|
||||
entry_id=entry_id, archive_current=True, debug=True
|
||||
)
|
||||
if result["success"]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Album entry set as current profile picture",
|
||||
"archived_entry_id": result.get("archived_entry_id")
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
class AlbumCropRequest(BaseModel):
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@app.post("/profile-picture/album/{entry_id}/manual-crop")
|
||||
async def manual_crop_album_entry(entry_id: str, req: AlbumCropRequest):
|
||||
"""Manually crop an album entry's original image"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
result = await profile_picture_manager.manual_crop_album_entry(
|
||||
entry_id=entry_id, x=req.x, y=req.y,
|
||||
width=req.width, height=req.height, debug=True
|
||||
)
|
||||
if result["success"]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Album entry cropped",
|
||||
"metadata": result.get("metadata", {})
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.post("/profile-picture/album/{entry_id}/auto-crop")
|
||||
async def auto_crop_album_entry(entry_id: str):
|
||||
"""Auto-crop an album entry using face/saliency detection"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
result = await profile_picture_manager.auto_crop_album_entry(
|
||||
entry_id=entry_id, debug=True
|
||||
)
|
||||
if result["success"]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Album entry auto-cropped",
|
||||
"metadata": result.get("metadata", {})
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
class AlbumDescriptionRequest(BaseModel):
|
||||
description: str
|
||||
|
||||
@app.post("/profile-picture/album/{entry_id}/description")
|
||||
async def update_album_entry_description(entry_id: str, req: AlbumDescriptionRequest):
|
||||
"""Update an album entry's description"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
result = await profile_picture_manager.update_album_entry_description(
|
||||
entry_id=entry_id, description=req.description, debug=True
|
||||
)
|
||||
if result["success"]:
|
||||
return {"status": "ok", "message": "Description updated"}
|
||||
else:
|
||||
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.delete("/profile-picture/album/{entry_id}")
|
||||
async def delete_album_entry(entry_id: str):
|
||||
"""Delete a single album entry"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
if profile_picture_manager.delete_album_entry(entry_id):
|
||||
return {"status": "ok", "message": "Album entry deleted"}
|
||||
else:
|
||||
return {"status": "error", "message": "Album entry not found"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
entry_ids: List[str]
|
||||
|
||||
@app.post("/profile-picture/album/delete-bulk")
|
||||
async def bulk_delete_album_entries(req: BulkDeleteRequest):
|
||||
"""Bulk delete multiple album entries"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
result = profile_picture_manager.delete_album_entries(req.entry_ids)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Deleted {result['deleted']}/{result['total']} entries",
|
||||
**result
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.post("/profile-picture/album/add-current")
|
||||
async def add_current_to_album():
|
||||
"""Archive the current profile picture into the album"""
|
||||
try:
|
||||
from utils.profile_picture_manager import profile_picture_manager
|
||||
entry_id = await profile_picture_manager._save_current_to_album(debug=True)
|
||||
if entry_id:
|
||||
return {"status": "ok", "message": "Current PFP archived to album", "entry_id": entry_id}
|
||||
else:
|
||||
return {"status": "error", "message": "No current PFP to archive"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@app.post("/manual/send")
|
||||
async def manual_send(
|
||||
message: str = Form(...),
|
||||
|
||||
Reference in New Issue
Block a user