Gallery & Media System
The Gallery & Media System captures photos and videos from Discord, organises them into a browsable gallery with voting/curation, and (in Phase 3) applies facial recognition to auto-tag people. It is designed for selecting media for newsletters, social posts and internal reports.
1. Purpose
Staff and participants share hundreds of photos and videos through Discord channels every month. Without intervention these images are lost to scrollback, Discord CDN expiry, and channel noise. The media system solves this by:
- Capturing -- automatically downloading every photo and video posted to Discord in real time.
- Organising -- storing files in a predictable year/month folder structure with metadata-rich filenames.
- Curating -- providing a heart/vote system so management can filter to the best media for reuse.
- Recognising (Phase 3) -- using on-device facial recognition to auto-tag people in photos.
2. Architecture Overview
Discord channels
│
▼
bot/media-grabber.js ← standalone Node.js worker (discord.js v14)
│
├─► NAS filesystem ← admin_drive/media/photos|videos/YYYY/MM-mon/
└─► PostgreSQL ← media.discord_media table
│
▼
backend/routes_v1p/gallery.js ← REST API for albums, media list, stats
│
▼
admin/src/js/pages/page_gallery.js ← Gallery UI with sidebar, lightbox, video player
3. Discord Media Grabber
File: bot/media-grabber.js
A standalone worker process that connects to Discord using the same bot token as the knowledge base sync but operates independently. It does NOT modify or patch kb-sync.js.
How It Works
- Connects to Discord with
GuildMessagesandMessageContentintents. - Listens for
messageCreateevents in real time. - For each message with attachments, checks file extension against known media types.
- Downloads the file to the NAS via
admin_drive/media/. - Inserts a metadata row into
media.discord_media.
File Storage Structure
admin_drive/media/
photos/
2026/
01-jan/
20260115-143022_brett_general.jpg
20260115-160845_ami_activities.png
02-feb/
...
03-mar/
...
videos/
2026/
03-mar/
20260314-091500_sean_events.mp4
bookface/ ← facial recognition reference library (Phase 3)
staff_142/
front.jpg
left.jpg
right.jpg
smile.jpg
participant_305/
front.jpg
...
Filename Convention
YYYYMMDD-HHmmss_authorname_channelname.ext
- Date/time from the Discord message timestamp (not download time)
- Author is the Discord username, sanitised to lowercase alphanumeric
- Channel is the Discord channel name, sanitised
- Extension preserved from the original file
Privacy & Exclusions
The grabber respects the same privacy rules as the knowledge base sync:
| Channel Set | Examples | Behaviour |
|---|---|---|
| Private | hr-confidential, incident-reports, performance-reviews, disciplinary, complaints, payroll | Skipped entirely |
| Excluded | bot-logs, bot-testing, dev-testing, bot-spam | Skipped entirely |
| All others | general, activities, events, etc. | Captured |
Supported Media Types
Photos: .jpg, .jpeg, .png, .gif, .webp, .bmp, .tiff, .heic
Videos: .mp4, .mov, .avi, .mkv, .webm, .wmv, .m4v, .flv
Any attachment that doesn't match these extensions is ignored.
Deduplication
Each attachment is uniquely identified by (discord_message_id, discord_attachment_index). The worker checks for duplicates before downloading, and the database has a UNIQUE constraint as a safety net. Re-processing the same message is safe.
Running
The media grabber starts automatically with the main server process. It logs activity to stdout:
[media-grabber] Logged in as ReginaldABS_III#9989
[media-grabber] Watching for photos and videos...
[media-grabber] downloading photo: IMG_2041.jpg -> media/photos/2026/03-mar/20260314-143022_brett_general.jpg
[media-grabber] saved: media/photos/2026/03-mar/20260314-143022_brett_general.jpg (245 KB)
4. Database Schema
Schema: media
media.discord_media
| Column | Type | Description |
|---|---|---|
id | SERIAL PK | Auto-increment ID |
discord_message_id | TEXT NOT NULL | Discord message snowflake |
discord_attachment_index | INT NOT NULL DEFAULT 0 | Position within multi-attachment messages |
discord_channel_id | TEXT | Discord channel snowflake |
channel_name | TEXT | Human-readable channel name |
author_id | TEXT | Discord user snowflake |
author_name | TEXT | Discord username at time of capture |
original_url | TEXT | Original Discord CDN URL (may expire) |
file_path | TEXT NOT NULL UNIQUE | Relative path from admin_drive root |
file_type | TEXT NOT NULL | photo or video |
file_size | BIGINT | File size in bytes |
width | INT | Pixel width (photos/videos with metadata) |
height | INT | Pixel height |
mime_type | TEXT | MIME type from Discord |
captured_at | TIMESTAMPTZ NOT NULL | When the file was downloaded |
message_created_at | TIMESTAMPTZ | When the Discord message was posted |
faces_processed | BOOLEAN NOT NULL DEFAULT FALSE | Whether facial recognition has run |
face_count | INT | Number of faces detected |
metadata | JSONB NOT NULL DEFAULT '{}' | Flexible store for EXIF, face embeddings, tags |
heart_count | INT NOT NULL DEFAULT 0 | Cached count of hearts (votes) |
has_super_heart | BOOLEAN NOT NULL DEFAULT FALSE | Cached flag: at least one super heart |
Constraints:
UNIQUE(discord_message_id, discord_attachment_index)-- deduplicationCHECK (file_type IN ('photo', 'video'))- Non-negative checks on
discord_attachment_index,file_size,width,height,face_count,heart_count
Indexes:
file_type-- filter by photo/videomessage_created_at DESC-- newest firstchannel_name-- filter by source channelauthor_name-- filter by who postedfaces_processed WHERE FALSE-- find unprocessed photosheart_count DESC-- sort by popularity
media.media_hearts
| Column | Type | Description |
|---|---|---|
id | SERIAL PK | Auto-increment ID |
media_id | INT NOT NULL FK | References discord_media(id) CASCADE |
user_id | UUID NOT NULL FK | References accounts.users(id) CASCADE |
is_super_heart | BOOLEAN NOT NULL DEFAULT FALSE | Whether this is a privileged super heart |
created_at | TIMESTAMPTZ NOT NULL | When the heart was given |
Constraint: UNIQUE(media_id, user_id) -- one heart per user per media item.
Trigger: trg_update_heart_counts fires after INSERT, UPDATE, or DELETE on media_hearts and updates the cached heart_count and has_super_heart columns on the parent discord_media row. Handles edge cases including media_id changes on UPDATE.
5. Heart / Vote System
The heart system provides three-tier curation for gallery media:
| Tier | Filter Rule | Use Case |
|---|---|---|
| All | No filter | Browse everything |
| Good | heart_count >= 2 | Community favourites |
| Best | heart_count >= 4 OR has_super_heart = TRUE | Newsletter/publication ready |
Regular Hearts
Any logged-in user can heart a media item. One heart per user per item. Hearts are togglable (click again to remove).
Super Hearts
A super heart instantly promotes media to the Best tier regardless of total heart count. This is designed for senior staff who curate content for newsletters, social media posts, and reports.
Super heart privilege is user-specific (not role-based). The list of authorised user IDs is stored in admin.app_settings under the key gallery_super_heart_user_ids and managed through:
Admin App Settings > Gallery & Media > Super Heart Users
This section provides a user search box to add/remove specific people. The intended initial users are the core management team who select media for external communications.
6. Gallery API
File: backend/routes_v1p/gallery.js
All endpoints require authentication (cookie or Bearer token).
GET /api/v1/gallery/albums
Returns the list of available albums (year/month folders) from the filesystem.
Query params:
type--photo,video, orboth(default:both)
Response:
{
"success": true,
"albums": [
{ "year": "2026", "month": "03-mar", "label": "MAR 2026", "count": 47, "types": ["photo", "video"] },
{ "year": "2026", "month": "02-feb", "label": "FEB 2026", "count": 123, "types": ["photo"] }
]
}
Albums are sorted newest first.
GET /api/v1/gallery/media
Returns media items for a specific album.
Query params:
year-- e.g.2026month-- e.g.03-martype--photo,video, orboth
Response: Array of media objects with file path, dimensions, heart counts, and metadata.
GET /api/v1/gallery/stats
Returns aggregate statistics (total photos, total videos, total hearts, etc.).
7. Gallery Page
Files:
admin/src/html/page_gallery.htmladmin/src/js/pages/page_gallery.js
Layout
The gallery page has three main areas:
-
Sidebar (left) -- Three collapsible sections:
- Filter -- All / Good / Best (heart-based tiers)
- Smart -- Placeholders for Participants, Staff, Venues, Events (Phase 3, facial recognition)
- Albums -- Year/month folders auto-built from filesystem, newest first
-
Header bar -- Breadcrumbs showing current location, plus a global toggle for Photos / Videos / Both (default Both)
-
Content area -- Grid of thumbnails. Photos open in PhotoSwipe lightbox; videos show an overlay player.
Media Serving
Media files are served through the admin-drive download endpoint (/api/v1/admin-drive/download?path=...) which uses cookie authentication. This means <img> and <video> tags work directly without fetch/blob workarounds -- the user just needs to be logged in.
8. Bookface -- Facial Recognition (Phase 3)
Bookface is the reference photo library for facial recognition. It lives at:
admin_drive/media/bookface/
Folder Structure
Each person gets a folder named with their staff or participant ID:
bookface/
staff_142/
front.jpg
left.jpg
right.jpg
smile.jpg
glasses.jpg
staff_87/
front.jpg
left.jpg
participant_305/
front.jpg
outdoor.jpg
The folder name IS the identity mapping -- no text files or manual configuration needed. The system parses staff_142 to look up the person's name from the database.
Recommended Reference Photos
For best recognition accuracy, capture 5-8 photos per person:
| Shot | Purpose |
|---|---|
| Face-on (neutral) | Baseline embedding |
| Slight left (~30 degrees) | Profile angle coverage |
| Slight right (~30 degrees) | Profile angle coverage |
| Smiling / laughing | Face shape variation |
| With glasses on | Accessory variation |
| With glasses off | Accessory variation |
| Outdoor lighting | Lighting variation |
| Indoor lighting | Lighting variation |
More reference photos = more robust matching. The system averages embeddings into a "centroid" per person, so each additional angle improves the match envelope.
Technology
Engine: ONNX Runtime with CUDA execution provider (onnxruntime-node)
Model: InsightFace ArcFace -- produces 512-dimension face embeddings (state-of-the-art accuracy)
Hardware: NVIDIA RTX 5080 (16GB VRAM) for GPU-accelerated inference (~50-100ms per photo vs 2-3s on CPU)
Recognition Flow:
- Background worker scans
media.discord_mediafor rows wherefaces_processed = FALSE - Loads the image, runs face detection to find bounding boxes
- For each face, generates a 512-dim ArcFace embedding
- Compares embedding against known reference embeddings (from bookface library) using Euclidean distance
- If distance < threshold (~0.6), tags the face with the person's identity
- Stores all embeddings and tags in
metadata.faces[]JSONB - Updates
face_countand setsfaces_processed = TRUE
Unknown Faces: Any detected face that doesn't match a known person within the confidence threshold is tagged as "unknown" and ignored for gallery purposes. This prevents false positives from bystanders, photobombers, or people not in the reference library.
Reference Library Updates: When new reference photos are added to a bookface folder, the system rebuilds that person's centroid embedding. Existing gallery photos do NOT need reprocessing -- the stored embeddings in metadata.faces[] can be re-compared against the updated reference library at any time.
Smart Albums (Phase 3)
Once facial recognition is operational, the gallery sidebar "Smart" section will populate with:
- Participants -- all media containing identified participants
- Staff -- all media containing identified staff members
- Venues -- location-tagged media (future, requires GPS/EXIF extraction)
- Events -- date-range clusters (future, requires event calendar integration)
9. SQL Migration
File: admin/tasks/sql/sql-current/20260314_media_schema.sql
Creates the media schema, both tables, indexes, the heart count trigger, and seeds the super heart app setting. Wrapped in a transaction (BEGIN/COMMIT).
This is a create script for fresh environments. If running on an existing database where the table already exists, the CREATE TABLE IF NOT EXISTS will skip creation but won't retrofit column changes.
10. Configuration
Environment Variables
No new environment variables are required. The media grabber reads from backend/.env:
ADMIN_DRIVE_DIR-- base path for file storage (NAS UNC path)DISCORD_BOT_TOKEN-- Discord bot authenticationDB2_HOST,DB2_NAME,DB2_USER,DB2_PASSWORD,DB2_PORT-- PostgreSQL connection
App Settings
| Key | Type | Description |
|---|---|---|
gallery_super_heart_user_ids | JSON array of UUIDs | Users authorised to give super hearts |
Managed via Admin App Settings > Gallery & Media.