diff --git a/bot/api.py b/bot/api.py
index ba1a20a..83d9a68 100644
--- a/bot/api.py
+++ b/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(...),
diff --git a/bot/static/index.html b/bot/static/index.html
index 771da2c..58988c7 100644
--- a/bot/static/index.html
+++ b/bot/static/index.html
@@ -738,6 +738,107 @@
border-color: #61dafb;
outline: none;
}
+ /* Album / Gallery grid */
+ .album-section {
+ margin: 1.5rem 0;
+ padding: 1rem;
+ background: #1a1a2e;
+ border: 1px solid #444;
+ border-radius: 8px;
+ }
+ .album-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+ }
+ .album-header h4 { margin: 0; }
+ .album-toolbar {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ align-items: center;
+ margin: 0.75rem 0;
+ }
+ .album-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 0.75rem;
+ max-height: 480px;
+ overflow-y: auto;
+ padding: 0.25rem;
+ }
+ .album-card {
+ position: relative;
+ border: 2px solid #444;
+ border-radius: 6px;
+ overflow: hidden;
+ cursor: pointer;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ background: #111;
+ }
+ .album-card:hover { border-color: #61dafb; }
+ .album-card.selected { border-color: #4CAF50; box-shadow: 0 0 8px rgba(76,175,80,0.4); }
+ .album-card.checked { border-color: #ff9800; }
+ .album-card img {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ display: block;
+ }
+ .album-card .album-check {
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ z-index: 2;
+ accent-color: #ff9800;
+ }
+ .album-card .album-card-info {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0,0,0,0.7);
+ padding: 2px 4px;
+ font-size: 0.7rem;
+ color: #ccc;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .album-card .color-dot {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ border: 1px solid #888;
+ vertical-align: middle;
+ margin-right: 3px;
+ }
+ .album-detail {
+ margin-top: 1rem;
+ padding: 1rem;
+ background: #222;
+ border: 1px solid #555;
+ border-radius: 8px;
+ }
+ .album-detail-previews {
+ display: flex;
+ gap: 1.5rem;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ margin: 1rem 0;
+ }
+ .album-detail-previews .pfp-preview-box img {
+ max-width: 300px;
+ max-height: 300px;
+ }
+ .album-disk-usage {
+ font-size: 0.8rem;
+ color: #888;
+ margin-left: auto;
+ }
@@ -1795,6 +1896,70 @@
+
+
Crop Mode:
@@ -3518,6 +3683,25 @@ async function loadPfpTab() {
// Refresh preview images
pfpRefreshPreviews();
+
+ // Update album header counts (without opening)
+ try {
+ const [listRes, usageRes] = await Promise.all([
+ apiCall('/profile-picture/album'),
+ apiCall('/profile-picture/album/disk-usage')
+ ]);
+ if (listRes.status === 'ok') {
+ albumEntries = listRes.entries || [];
+ document.getElementById('album-count').textContent = `(${albumEntries.length} entries)`;
+ if (albumOpen) albumRenderGrid();
+ }
+ if (usageRes.status === 'ok') {
+ document.getElementById('album-disk-usage').textContent =
+ `${usageRes.human_readable} ยท ${usageRes.entry_count} entries`;
+ }
+ } catch (e) {
+ console.error('Failed to load album info:', e);
+ }
}
// --- Danbooru Change ---
@@ -3878,6 +4062,353 @@ async function resetRoleColor() {
}
}
+// ============================================================================
+// Album / Gallery System
+// ============================================================================
+
+let albumEntries = [];
+let albumSelectedId = null; // currently-viewed entry
+let albumChecked = new Set(); // checked for bulk delete
+let albumCropper = null;
+let albumOpen = false;
+
+function albumSetStatus(text, color = '#61dafb') {
+ const el = document.getElementById('album-status');
+ if (el) { el.textContent = text; el.style.color = color; }
+}
+
+function albumToggle() {
+ albumOpen = !albumOpen;
+ document.getElementById('album-body').style.display = albumOpen ? 'block' : 'none';
+ document.getElementById('album-toggle-icon').textContent = albumOpen ? 'โผ' : 'โถ';
+ if (albumOpen) albumLoad();
+}
+
+async function albumLoad() {
+ try {
+ const [listRes, usageRes] = await Promise.all([
+ apiCall('/profile-picture/album'),
+ apiCall('/profile-picture/album/disk-usage')
+ ]);
+ if (listRes.status === 'ok') {
+ albumEntries = listRes.entries || [];
+ document.getElementById('album-count').textContent = `(${albumEntries.length} entries)`;
+ albumRenderGrid();
+ }
+ if (usageRes.status === 'ok') {
+ document.getElementById('album-disk-usage').textContent =
+ `${usageRes.human_readable} ยท ${usageRes.entry_count} entries`;
+ }
+ } catch (e) {
+ console.error('Album load error:', e);
+ }
+}
+
+function albumRenderGrid() {
+ const grid = document.getElementById('album-grid');
+ if (!grid) return;
+
+ if (albumEntries.length === 0) {
+ grid.innerHTML = '
No album entries yet. Upload images or archive the current PFP.
';
+ return;
+ }
+
+ grid.innerHTML = albumEntries.map(e => {
+ const id = e.id;
+ const isSelected = id === albumSelectedId;
+ const isChecked = albumChecked.has(id);
+ const colorDot = e.dominant_color
+ ? `
`
+ : '';
+ const label = (e.source || '').replace('custom_upload', 'upload').substring(0, 12);
+ return `
+
+
})
+
${colorDot}${label}
+
`;
+ }).join('');
+}
+
+function albumToggleCheck(id, checked) {
+ if (checked) albumChecked.add(id); else albumChecked.delete(id);
+ document.getElementById('album-selected-count').textContent = albumChecked.size;
+ document.getElementById('album-bulk-delete-btn').disabled = albumChecked.size === 0;
+ // update card class
+ const card = document.querySelector(`.album-card[data-id="${id}"]`);
+ if (card) card.classList.toggle('checked', checked);
+}
+
+async function albumSelectEntry(id) {
+ albumSelectedId = id;
+ // highlight card
+ document.querySelectorAll('.album-card').forEach(c => c.classList.toggle('selected', c.dataset.id === id));
+ // show detail
+ const detail = document.getElementById('album-detail');
+ detail.style.display = 'block';
+ const t = Date.now();
+ document.getElementById('album-detail-original').src = `/profile-picture/album/${id}/image/original?t=${t}`;
+ document.getElementById('album-detail-cropped').src = `/profile-picture/album/${id}/image/cropped?t=${t}`;
+
+ // load entry metadata
+ try {
+ const res = await apiCall(`/profile-picture/album/${id}`);
+ if (res.status === 'ok' && res.entry) {
+ const e = res.entry;
+ document.getElementById('album-detail-dims').textContent =
+ e.original_width && e.original_height ? `${e.original_width}ร${e.original_height}` : '';
+ document.getElementById('album-detail-description').value = e.description || '';
+ const metaLines = [];
+ if (e.added_at) metaLines.push(`Added: ${new Date(e.added_at).toLocaleString()}`);
+ if (e.source) metaLines.push(`Source: ${e.source}`);
+ if (e.dominant_color) metaLines.push(`Color: ${e.dominant_color.hex}`);
+ if (e.was_current) metaLines.push('๐ Previously active');
+ document.getElementById('album-detail-meta').textContent = metaLines.join(' ยท ');
+ }
+ } catch (e) {
+ console.error('Album entry load error:', e);
+ }
+}
+
+function albumCloseDetail() {
+ document.getElementById('album-detail').style.display = 'none';
+ albumSelectedId = null;
+ albumHideCropInterface();
+ document.querySelectorAll('.album-card').forEach(c => c.classList.remove('selected'));
+}
+
+// --- Album Upload ---
+async function albumUpload() {
+ const input = document.getElementById('album-upload');
+ if (!input.files || input.files.length === 0) {
+ showNotification('Select image(s) to add to album', 'error');
+ return;
+ }
+
+ const files = Array.from(input.files);
+ albumSetStatus(`โณ Adding ${files.length} image(s) to album...`);
+
+ try {
+ if (files.length === 1) {
+ const formData = new FormData();
+ formData.append('file', files[0]);
+ const resp = await fetch('/profile-picture/album/add', { method: 'POST', body: formData });
+ const result = await resp.json();
+ if (result.status === 'ok') {
+ albumSetStatus(`โ
Added to album`, 'green');
+ showNotification('Image added to album!');
+ } else {
+ throw new Error(result.message);
+ }
+ } else {
+ const formData = new FormData();
+ files.forEach(f => formData.append('files', f));
+ const resp = await fetch('/profile-picture/album/add-batch', { method: 'POST', body: formData });
+ const result = await resp.json();
+ albumSetStatus(`โ
${result.message}`, result.failed > 0 ? 'orange' : 'green');
+ showNotification(result.message);
+ }
+ input.value = '';
+ await albumLoad();
+ } catch (error) {
+ console.error('Album upload error:', error);
+ albumSetStatus(`โ Error: ${error.message}`, 'red');
+ }
+}
+
+async function albumAddCurrent() {
+ albumSetStatus('โณ Archiving current PFP...');
+ try {
+ const result = await apiCall('/profile-picture/album/add-current', 'POST');
+ if (result.status === 'ok') {
+ albumSetStatus(`โ
${result.message}`, 'green');
+ showNotification('Current PFP archived to album!');
+ await albumLoad();
+ } else {
+ throw new Error(result.message);
+ }
+ } catch (error) {
+ albumSetStatus(`โ Error: ${error.message}`, 'red');
+ }
+}
+
+// --- Album Set as Current ---
+async function albumSetAsCurrent() {
+ if (!albumSelectedId) return;
+ if (!confirm('Set this album entry as your current Discord profile picture?\nThe current PFP will be archived to the album.')) return;
+
+ albumSetStatus('โณ Setting as current PFP...');
+ try {
+ const result = await apiCall(`/profile-picture/album/${albumSelectedId}/set-current`, 'POST');
+ if (result.status === 'ok') {
+ albumSetStatus(`โ
${result.message}`, 'green');
+ showNotification('Album entry set as current PFP!');
+ pfpRefreshPreviews();
+ loadPfpTab(); // refresh metadata + description
+ await albumLoad();
+ } else {
+ throw new Error(result.message);
+ }
+ } catch (error) {
+ albumSetStatus(`โ Error: ${error.message}`, 'red');
+ }
+}
+
+// --- Album Delete ---
+async function albumDeleteSelected() {
+ if (!albumSelectedId) return;
+ if (!confirm('Delete this album entry permanently?')) return;
+
+ try {
+ const resp = await fetch(`/profile-picture/album/${albumSelectedId}`, { method: 'DELETE' });
+ const result = await resp.json();
+ if (result.status === 'ok') {
+ showNotification('Album entry deleted');
+ albumCloseDetail();
+ await albumLoad();
+ } else {
+ throw new Error(result.message);
+ }
+ } catch (error) {
+ showNotification(`Error: ${error.message}`, 'error');
+ }
+}
+
+async function albumBulkDelete() {
+ if (albumChecked.size === 0) return;
+ if (!confirm(`Delete ${albumChecked.size} selected album entries permanently?`)) return;
+
+ albumSetStatus(`โณ Deleting ${albumChecked.size} entries...`);
+ try {
+ const resp = await fetch('/profile-picture/album/delete-bulk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ entry_ids: Array.from(albumChecked) })
+ });
+ const result = await resp.json();
+ albumSetStatus(`โ
${result.message}`, 'green');
+ showNotification(result.message);
+ albumChecked.clear();
+ document.getElementById('album-selected-count').textContent = '0';
+ document.getElementById('album-bulk-delete-btn').disabled = true;
+ if (albumSelectedId && !albumEntries.find(e => e.id === albumSelectedId)) {
+ albumCloseDetail();
+ }
+ await albumLoad();
+ } catch (error) {
+ albumSetStatus(`โ Error: ${error.message}`, 'red');
+ }
+}
+
+// --- Album Crop ---
+function albumShowCropInterface() {
+ if (!albumSelectedId) return;
+ if (albumCropper) { albumCropper.destroy(); albumCropper = null; }
+
+ const section = document.getElementById('album-crop-section');
+ const img = document.getElementById('album-crop-image');
+ img.src = `/profile-picture/album/${albumSelectedId}/image/original?t=${Date.now()}`;
+ section.style.display = 'block';
+
+ img.onload = function() {
+ albumCropper = new Cropper(img, {
+ aspectRatio: 1,
+ viewMode: 2,
+ minCropBoxWidth: 64,
+ minCropBoxHeight: 64,
+ responsive: true,
+ autoCropArea: 0.8,
+ background: true,
+ guides: true,
+ center: true,
+ highlight: true,
+ cropBoxMovable: true,
+ cropBoxResizable: true,
+ toggleDragModeOnDblclick: false
+ });
+ };
+}
+
+function albumHideCropInterface() {
+ if (albumCropper) { albumCropper.destroy(); albumCropper = null; }
+ document.getElementById('album-crop-section').style.display = 'none';
+}
+
+async function albumApplyManualCrop() {
+ if (!albumCropper || !albumSelectedId) return;
+ const data = albumCropper.getData(true);
+ albumSetStatus('โณ Applying crop to album entry...');
+
+ try {
+ const resp = await fetch(`/profile-picture/album/${albumSelectedId}/manual-crop`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ x: Math.round(data.x), y: Math.round(data.y), width: Math.round(data.width), height: Math.round(data.height) })
+ });
+ const result = await resp.json();
+ if (result.status === 'ok') {
+ albumSetStatus('โ
Crop applied', 'green');
+ showNotification('Album entry cropped!');
+ albumHideCropInterface();
+ // refresh detail images
+ const t = Date.now();
+ document.getElementById('album-detail-cropped').src = `/profile-picture/album/${albumSelectedId}/image/cropped?t=${t}`;
+ await albumLoad(); // refresh grid thumbnails
+ } else {
+ throw new Error(result.message);
+ }
+ } catch (error) {
+ albumSetStatus(`โ Error: ${error.message}`, 'red');
+ }
+}
+
+async function albumApplyAutoCrop() {
+ if (!albumSelectedId) return;
+ albumSetStatus('โณ Running auto-crop on album entry...');
+
+ try {
+ const result = await apiCall(`/profile-picture/album/${albumSelectedId}/auto-crop`, 'POST');
+ if (result.status === 'ok') {
+ albumSetStatus('โ
Auto-crop applied', 'green');
+ showNotification('Album entry auto-cropped!');
+ albumHideCropInterface();
+ const t = Date.now();
+ document.getElementById('album-detail-cropped').src = `/profile-picture/album/${albumSelectedId}/image/cropped?t=${t}`;
+ await albumLoad();
+ } else {
+ throw new Error(result.message);
+ }
+ } catch (error) {
+ albumSetStatus(`โ Error: ${error.message}`, 'red');
+ }
+}
+
+// --- Album Description ---
+async function albumSaveDescription() {
+ if (!albumSelectedId) return;
+ const description = document.getElementById('album-detail-description').value.trim();
+ if (!description) { showNotification('Description cannot be empty', 'error'); return; }
+
+ try {
+ const resp = await fetch(`/profile-picture/album/${albumSelectedId}/description`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ description })
+ });
+ const result = await resp.json();
+ if (result.status === 'ok') {
+ showNotification('Album entry description saved!');
+ } else {
+ throw new Error(result.message);
+ }
+ } catch (error) {
+ showNotification(`Error: ${error.message}`, 'error');
+ }
+}
+
// Toggle functions for custom prompt and manual message target selection
function toggleCustomPromptTarget() {
const targetType = document.getElementById('custom-prompt-target-type').value;
diff --git a/bot/utils/profile_picture_manager.py b/bot/utils/profile_picture_manager.py
index ec18f16..6b81252 100644
--- a/bot/utils/profile_picture_manager.py
+++ b/bot/utils/profile_picture_manager.py
@@ -13,6 +13,8 @@ Supports both static images and animated GIFs:
import os
import io
+import uuid
+import shutil
import aiohttp
import asyncio
from PIL import Image, ImageDraw
@@ -39,6 +41,7 @@ class ProfilePictureManager:
CURRENT_PATH = "memory/profile_pictures/current.png"
ORIGINAL_PATH = "memory/profile_pictures/original.png"
METADATA_PATH = "memory/profile_pictures/metadata.json"
+ ALBUM_DIR = "memory/profile_pictures/album"
# Face detection API endpoint
FACE_DETECTOR_API = "http://anime-face-detector:6078/detect"
@@ -63,6 +66,7 @@ class ProfilePictureManager:
def _ensure_directories(self):
"""Ensure profile picture directory exists"""
os.makedirs(self.PROFILE_PIC_DIR, exist_ok=True)
+ os.makedirs(self.ALBUM_DIR, exist_ok=True)
async def initialize(self):
"""Initialize the profile picture manager (check API availability)"""
@@ -1753,6 +1757,615 @@ Respond in JSON format:
return result
+ # =========================================================================
+ # Album / Gallery System
+ # =========================================================================
+
+ def _album_entry_dir(self, entry_id: str) -> str:
+ """Return the directory path for an album entry."""
+ return os.path.join(self.ALBUM_DIR, entry_id)
+
+ def _save_album_metadata(self, entry_id: str, metadata: Dict):
+ """Save metadata for an album entry."""
+ entry_dir = self._album_entry_dir(entry_id)
+ os.makedirs(entry_dir, exist_ok=True)
+ meta_path = os.path.join(entry_dir, "metadata.json")
+ with open(meta_path, 'w') as f:
+ json.dump(metadata, f, indent=2)
+
+ def _load_album_metadata(self, entry_id: str) -> Optional[Dict]:
+ """Load metadata for an album entry."""
+ meta_path = os.path.join(self._album_entry_dir(entry_id), "metadata.json")
+ try:
+ if os.path.exists(meta_path):
+ with open(meta_path, 'r') as f:
+ return json.load(f)
+ except Exception as e:
+ logger.error(f"Error loading album metadata for {entry_id}: {e}")
+ return None
+
+ async def _save_current_to_album(self, debug: bool = False) -> Optional[str]:
+ """
+ Archive the current PFP (original + cropped + metadata) into the album.
+
+ Returns:
+ The new album entry ID, or None if nothing to archive.
+ """
+ if not os.path.exists(self.ORIGINAL_PATH) and not os.path.exists(self.CURRENT_PATH):
+ if debug:
+ logger.info("No current PFP to archive")
+ return None
+
+ entry_id = str(uuid.uuid4())
+ entry_dir = self._album_entry_dir(entry_id)
+ os.makedirs(entry_dir, exist_ok=True)
+
+ # Copy images
+ if os.path.exists(self.ORIGINAL_PATH):
+ shutil.copy2(self.ORIGINAL_PATH, os.path.join(entry_dir, "original.png"))
+ if os.path.exists(self.CURRENT_PATH):
+ shutil.copy2(self.CURRENT_PATH, os.path.join(entry_dir, "cropped.png"))
+
+ # Build metadata from current
+ current_meta = self.load_metadata() or {}
+ description = self.get_current_description() or ""
+
+ album_meta = {
+ "id": entry_id,
+ "added_at": datetime.now().isoformat(),
+ "source": current_meta.get("source", "unknown"),
+ "description": description,
+ "dominant_color": current_meta.get("dominant_color"),
+ "original_width": current_meta.get("original_width"),
+ "original_height": current_meta.get("original_height"),
+ "crop_region": current_meta.get("crop_region"),
+ "was_current": True,
+ "archived_at": datetime.now().isoformat(),
+ }
+ # Carry over Danbooru metadata if present
+ for key in ("danbooru_post_id", "danbooru_tags", "danbooru_artist",
+ "danbooru_rating", "danbooru_score", "danbooru_file_url"):
+ if key in current_meta:
+ album_meta[key] = current_meta[key]
+
+ self._save_album_metadata(entry_id, album_meta)
+
+ if debug:
+ logger.info(f"Archived current PFP to album entry {entry_id}")
+ return entry_id
+
+ async def add_to_album(
+ self,
+ image_bytes: bytes,
+ source: str = "custom_upload",
+ extra_metadata: Optional[Dict] = None,
+ generate_description: bool = True,
+ auto_crop: bool = True,
+ debug: bool = False,
+ _batch_mode: bool = False,
+ ) -> Dict:
+ """
+ Add an image to the album gallery.
+
+ Args:
+ image_bytes: Raw image bytes
+ source: Source label (e.g. "custom_upload", "danbooru")
+ extra_metadata: Additional metadata to store (Danbooru info, etc.)
+ generate_description: Whether to generate a vision-model description
+ auto_crop: Whether to auto-crop (face detection โ saliency)
+ debug: Enable debug output
+ _batch_mode: Internal flag โ if True, skip starting/stopping face detector
+ (caller manages detector lifecycle)
+
+ Returns:
+ Dict with success, entry_id, metadata
+ """
+ result = {"success": False, "error": None, "entry_id": None, "metadata": {}}
+
+ try:
+ image = Image.open(io.BytesIO(image_bytes))
+ if image.mode not in ("RGB", "RGBA"):
+ image = image.convert("RGB")
+
+ entry_id = str(uuid.uuid4())
+ entry_dir = self._album_entry_dir(entry_id)
+ os.makedirs(entry_dir, exist_ok=True)
+
+ # Save original
+ original_path = os.path.join(entry_dir, "original.png")
+ with open(original_path, 'wb') as f:
+ f.write(image_bytes)
+
+ album_meta: Dict = {
+ "id": entry_id,
+ "added_at": datetime.now().isoformat(),
+ "source": source,
+ "original_width": image.size[0],
+ "original_height": image.size[1],
+ }
+ if extra_metadata:
+ album_meta.update(extra_metadata)
+
+ # Generate description
+ if generate_description:
+ if debug:
+ logger.info(f"[Album {entry_id[:8]}] Generating description...")
+ description = await self._generate_image_description(image_bytes, debug=debug)
+ if description:
+ album_meta["description"] = description
+ if debug:
+ logger.info(f"[Album {entry_id[:8]}] Description: {description[:80]}...")
+
+ # Auto-crop
+ if auto_crop:
+ target_size = 512
+ w, h = image.size
+ if w <= target_size and h <= target_size:
+ cropped_image = image
+ else:
+ if _batch_mode:
+ # In batch mode the face detector is already running
+ cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
+ else:
+ cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
+
+ if cropped_image:
+ buf = io.BytesIO()
+ cropped_image.save(buf, format='PNG')
+ cropped_bytes = buf.getvalue()
+ cropped_path = os.path.join(entry_dir, "cropped.png")
+ with open(cropped_path, 'wb') as f:
+ f.write(cropped_bytes)
+
+ # Extract dominant color from cropped
+ dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
+ if dominant_color:
+ album_meta["dominant_color"] = {
+ "rgb": dominant_color,
+ "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
+ }
+ else:
+ if debug:
+ logger.warning(f"[Album {entry_id[:8]}] Auto-crop failed, using original resized")
+ # Fallback โ resize original to square
+ cropped_image = image.copy()
+ cropped_image.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
+ buf = io.BytesIO()
+ cropped_image.save(buf, format='PNG')
+ with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
+ f.write(buf.getvalue())
+
+ self._save_album_metadata(entry_id, album_meta)
+
+ result["success"] = True
+ result["entry_id"] = entry_id
+ result["metadata"] = album_meta
+ if debug:
+ logger.info(f"[Album] Added entry {entry_id}")
+
+ except Exception as e:
+ result["error"] = f"Error adding to album: {e}"
+ logger.error(f"Error in add_to_album: {e}")
+ import traceback
+ traceback.print_exc()
+
+ return result
+
+ async def add_batch_to_album(
+ self,
+ images: List[Dict],
+ debug: bool = False,
+ ) -> Dict:
+ """
+ Batch-add multiple images to the album efficiently.
+ Keeps the vision model loaded and the face detector running across all images.
+
+ Args:
+ images: List of dicts, each with 'bytes' (raw image bytes) and optional 'source', 'extra_metadata'
+ debug: Enable debug output
+
+ Returns:
+ Dict with results list and summary counts
+ """
+ results = []
+ success_count = 0
+ fail_count = 0
+ face_detector_started = False
+
+ try:
+ # Pre-start face detector for the whole batch
+ await self._ensure_vram_available(debug=debug)
+ if await self._start_face_detector(debug=debug):
+ face_detector_started = True
+ if debug:
+ logger.info(f"[Album Batch] Face detector started for {len(images)} images")
+
+ for i, img_info in enumerate(images):
+ if debug:
+ logger.info(f"[Album Batch] Processing image {i+1}/{len(images)}...")
+
+ entry_result = await self.add_to_album(
+ image_bytes=img_info["bytes"],
+ source=img_info.get("source", "custom_upload"),
+ extra_metadata=img_info.get("extra_metadata"),
+ generate_description=True,
+ auto_crop=True,
+ debug=debug,
+ _batch_mode=True,
+ )
+ results.append(entry_result)
+ if entry_result["success"]:
+ success_count += 1
+ else:
+ fail_count += 1
+
+ except Exception as e:
+ logger.error(f"Error in batch album add: {e}")
+ finally:
+ # Clean up face detector after entire batch
+ if face_detector_started:
+ await self._stop_face_detector(debug=debug)
+
+ return {
+ "success": fail_count == 0,
+ "total": len(images),
+ "succeeded": success_count,
+ "failed": fail_count,
+ "results": results,
+ }
+
+ def get_album_entries(self) -> List[Dict]:
+ """
+ List all album entries with their metadata, sorted newest-first.
+
+ Returns:
+ List of metadata dicts
+ """
+ entries = []
+ if not os.path.exists(self.ALBUM_DIR):
+ return entries
+
+ for name in os.listdir(self.ALBUM_DIR):
+ entry_dir = os.path.join(self.ALBUM_DIR, name)
+ if not os.path.isdir(entry_dir):
+ continue
+ meta = self._load_album_metadata(name)
+ if meta:
+ # Add convenience flags
+ meta["has_original"] = os.path.exists(os.path.join(entry_dir, "original.png"))
+ meta["has_cropped"] = os.path.exists(os.path.join(entry_dir, "cropped.png"))
+ entries.append(meta)
+
+ # Sort newest first
+ entries.sort(key=lambda e: e.get("added_at", ""), reverse=True)
+ return entries
+
+ def get_album_entry(self, entry_id: str) -> Optional[Dict]:
+ """Get metadata for a single album entry."""
+ entry_dir = self._album_entry_dir(entry_id)
+ if not os.path.isdir(entry_dir):
+ return None
+ meta = self._load_album_metadata(entry_id)
+ if meta:
+ meta["has_original"] = os.path.exists(os.path.join(entry_dir, "original.png"))
+ meta["has_cropped"] = os.path.exists(os.path.join(entry_dir, "cropped.png"))
+ return meta
+
+ def get_album_image_path(self, entry_id: str, image_type: str = "cropped") -> Optional[str]:
+ """
+ Get the file path for an album entry's image.
+
+ Args:
+ entry_id: Album entry UUID
+ image_type: 'original' or 'cropped'
+
+ Returns:
+ Absolute path string or None if not found
+ """
+ filename = "original.png" if image_type == "original" else "cropped.png"
+ path = os.path.join(self._album_entry_dir(entry_id), filename)
+ return path if os.path.exists(path) else None
+
+ async def set_album_entry_as_current(self, entry_id: str, archive_current: bool = True, debug: bool = False) -> Dict:
+ """
+ Set an album entry as the current Discord profile picture.
+ Optionally archives the current PFP into the album first.
+
+ Args:
+ entry_id: Album entry UUID
+ archive_current: Whether to archive current PFP before replacing
+ debug: Enable debug output
+
+ Returns:
+ Dict with success status
+ """
+ result = {"success": False, "error": None, "archived_entry_id": None}
+
+ try:
+ entry_dir = self._album_entry_dir(entry_id)
+ meta = self._load_album_metadata(entry_id)
+ if not meta:
+ result["error"] = f"Album entry {entry_id} not found"
+ return result
+
+ # Find the best image to use โ prefer cropped, fall back to original
+ cropped_path = os.path.join(entry_dir, "cropped.png")
+ original_path = os.path.join(entry_dir, "original.png")
+
+ if not os.path.exists(cropped_path) and not os.path.exists(original_path):
+ result["error"] = "Album entry has no images"
+ return result
+
+ # Archive current PFP first
+ if archive_current and (os.path.exists(self.ORIGINAL_PATH) or os.path.exists(self.CURRENT_PATH)):
+ archived_id = await self._save_current_to_album(debug=debug)
+ result["archived_entry_id"] = archived_id
+
+ # Copy album entry images to current slots
+ if os.path.exists(original_path):
+ shutil.copy2(original_path, self.ORIGINAL_PATH)
+ if os.path.exists(cropped_path):
+ shutil.copy2(cropped_path, self.CURRENT_PATH)
+ elif os.path.exists(original_path):
+ # No cropped version โ copy original as current too
+ shutil.copy2(original_path, self.CURRENT_PATH)
+
+ # Read the avatar bytes for Discord
+ avatar_path = self.CURRENT_PATH if os.path.exists(self.CURRENT_PATH) else self.ORIGINAL_PATH
+ with open(avatar_path, 'rb') as f:
+ avatar_bytes = f.read()
+
+ # Update Discord avatar
+ if globals.client and globals.client.user:
+ try:
+ if globals.client.loop and globals.client.loop.is_running():
+ future = asyncio.run_coroutine_threadsafe(
+ globals.client.user.edit(avatar=avatar_bytes),
+ globals.client.loop
+ )
+ future.result(timeout=10)
+ else:
+ await globals.client.user.edit(avatar=avatar_bytes)
+
+ result["success"] = True
+ logger.info(f"Set album entry {entry_id} as current PFP")
+ except discord.HTTPException as e:
+ result["error"] = f"Discord API error: {e}"
+ return result
+ except Exception as e:
+ result["error"] = f"Unexpected error updating avatar: {e}"
+ return result
+ else:
+ result["error"] = "Bot client not ready"
+ return result
+
+ # Update current metadata
+ new_meta = {
+ "source": meta.get("source", "album"),
+ "changed_at": datetime.now().isoformat(),
+ "album_entry_id": entry_id,
+ "original_width": meta.get("original_width"),
+ "original_height": meta.get("original_height"),
+ "dominant_color": meta.get("dominant_color"),
+ "crop_region": meta.get("crop_region"),
+ }
+ self._save_metadata(new_meta)
+
+ # Update description
+ description = meta.get("description", "")
+ if description:
+ desc_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
+ with open(desc_path, 'w', encoding='utf-8') as f:
+ f.write(description)
+
+ # Update role colors
+ dom_color = meta.get("dominant_color")
+ if dom_color and "rgb" in dom_color:
+ color_tuple = tuple(dom_color["rgb"])
+ await self._update_role_colors(color_tuple, debug=debug)
+
+ # Update bipolar webhooks
+ if globals.BIPOLAR_MODE:
+ try:
+ from utils.bipolar_mode import update_webhook_avatars
+ await update_webhook_avatars(globals.client)
+ except Exception as e:
+ logger.warning(f"Failed to update bipolar webhook avatars: {e}")
+
+ except Exception as e:
+ result["error"] = f"Error setting album entry as current: {e}"
+ logger.error(f"Error in set_album_entry_as_current: {e}")
+
+ return result
+
+ def delete_album_entry(self, entry_id: str) -> bool:
+ """Delete a single album entry and its files."""
+ entry_dir = self._album_entry_dir(entry_id)
+ if os.path.isdir(entry_dir):
+ shutil.rmtree(entry_dir)
+ logger.info(f"Deleted album entry {entry_id}")
+ return True
+ return False
+
+ def delete_album_entries(self, entry_ids: List[str]) -> Dict:
+ """Bulk delete multiple album entries."""
+ deleted = 0
+ failed = 0
+ for eid in entry_ids:
+ if self.delete_album_entry(eid):
+ deleted += 1
+ else:
+ failed += 1
+ return {"deleted": deleted, "failed": failed, "total": len(entry_ids)}
+
+ def get_album_disk_usage(self) -> Dict:
+ """Calculate total disk usage of the album directory."""
+ total_bytes = 0
+ entry_count = 0
+ if os.path.exists(self.ALBUM_DIR):
+ for name in os.listdir(self.ALBUM_DIR):
+ entry_dir = os.path.join(self.ALBUM_DIR, name)
+ if not os.path.isdir(entry_dir):
+ continue
+ entry_count += 1
+ for fname in os.listdir(entry_dir):
+ fpath = os.path.join(entry_dir, fname)
+ if os.path.isfile(fpath):
+ total_bytes += os.path.getsize(fpath)
+
+ # Human-readable
+ if total_bytes < 1024:
+ human = f"{total_bytes} B"
+ elif total_bytes < 1024 * 1024:
+ human = f"{total_bytes / 1024:.1f} KB"
+ elif total_bytes < 1024 * 1024 * 1024:
+ human = f"{total_bytes / (1024*1024):.1f} MB"
+ else:
+ human = f"{total_bytes / (1024*1024*1024):.2f} GB"
+
+ return {
+ "total_bytes": total_bytes,
+ "human_readable": human,
+ "entry_count": entry_count,
+ }
+
+ async def update_album_entry_description(self, entry_id: str, description: str, debug: bool = False) -> Dict:
+ """Update the description of an album entry."""
+ result = {"success": False, "error": None}
+ meta = self._load_album_metadata(entry_id)
+ if not meta:
+ result["error"] = f"Album entry {entry_id} not found"
+ return result
+ meta["description"] = description
+ self._save_album_metadata(entry_id, meta)
+ result["success"] = True
+ if debug:
+ logger.info(f"Updated description for album entry {entry_id}")
+ return result
+
+ async def manual_crop_album_entry(
+ self, entry_id: str, x: int, y: int, width: int, height: int,
+ target_size: int = 512, debug: bool = False
+ ) -> Dict:
+ """
+ Manually crop an album entry's original image.
+ Does NOT apply to Discord โ only updates the album entry's cropped.png.
+ """
+ result = {"success": False, "error": None, "metadata": {}}
+
+ entry_dir = self._album_entry_dir(entry_id)
+ original_path = os.path.join(entry_dir, "original.png")
+
+ if not os.path.exists(original_path):
+ result["error"] = f"No original image for album entry {entry_id}"
+ return result
+
+ try:
+ image = Image.open(original_path)
+ img_w, img_h = image.size
+
+ if x < 0 or y < 0:
+ result["error"] = f"Crop coordinates must be non-negative"
+ return result
+ if x + width > img_w or y + height > img_h:
+ result["error"] = f"Crop region exceeds image bounds ({img_w}x{img_h})"
+ return result
+ if width < 64 or height < 64:
+ result["error"] = f"Crop region too small (min 64x64)"
+ return result
+
+ cropped = image.crop((x, y, x + width, y + height))
+ cropped = cropped.resize((target_size, target_size), Image.Resampling.LANCZOS)
+
+ buf = io.BytesIO()
+ cropped.save(buf, format='PNG')
+ cropped_bytes = buf.getvalue()
+
+ with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
+ f.write(cropped_bytes)
+
+ # Update metadata
+ meta = self._load_album_metadata(entry_id) or {}
+ meta["crop_region"] = {"x": x, "y": y, "width": width, "height": height}
+
+ # Re-extract dominant color
+ dominant_color = self._extract_dominant_color(cropped, debug=debug)
+ if dominant_color:
+ meta["dominant_color"] = {
+ "rgb": dominant_color,
+ "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
+ }
+ self._save_album_metadata(entry_id, meta)
+
+ result["success"] = True
+ result["metadata"] = meta
+ if debug:
+ logger.info(f"Manual crop applied to album entry {entry_id}")
+
+ except Exception as e:
+ result["error"] = f"Error cropping album entry: {e}"
+ logger.error(f"Error in manual_crop_album_entry: {e}")
+
+ return result
+
+ async def auto_crop_album_entry(self, entry_id: str, debug: bool = False) -> Dict:
+ """
+ Auto-crop an album entry's original image (face detection โ saliency).
+ Does NOT apply to Discord โ only updates the album entry's cropped.png.
+ """
+ result = {"success": False, "error": None, "metadata": {}}
+
+ entry_dir = self._album_entry_dir(entry_id)
+ original_path = os.path.join(entry_dir, "original.png")
+
+ if not os.path.exists(original_path):
+ result["error"] = f"No original image for album entry {entry_id}"
+ return result
+
+ try:
+ with open(original_path, 'rb') as f:
+ image_bytes = f.read()
+ image = Image.open(io.BytesIO(image_bytes))
+
+ target_size = 512
+ w, h = image.size
+ if w <= target_size and h <= target_size:
+ cropped_image = image
+ else:
+ cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
+
+ if not cropped_image:
+ result["error"] = "Auto-crop failed"
+ return result
+
+ buf = io.BytesIO()
+ cropped_image.save(buf, format='PNG')
+ with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
+ f.write(buf.getvalue())
+
+ # Update metadata
+ meta = self._load_album_metadata(entry_id) or {}
+ meta.pop("crop_region", None) # auto-crop doesn't have user-defined region
+
+ dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
+ if dominant_color:
+ meta["dominant_color"] = {
+ "rgb": dominant_color,
+ "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
+ }
+ self._save_album_metadata(entry_id, meta)
+
+ result["success"] = True
+ result["metadata"] = meta
+ if debug:
+ logger.info(f"Auto-crop applied to album entry {entry_id}")
+
+ except Exception as e:
+ result["error"] = f"Error auto-cropping album entry: {e}"
+ logger.error(f"Error in auto_crop_album_entry: {e}")
+
+ return result
+
# Global instance
profile_picture_manager = ProfilePictureManager()