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:
2026-03-31 15:09:57 +03:00
parent f092cadb9d
commit 56a70705b2
3 changed files with 1371 additions and 0 deletions

View File

@@ -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(...),