Skip to main content

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:

  1. Capturing -- automatically downloading every photo and video posted to Discord in real time.
  2. Organising -- storing files in a predictable year/month folder structure with metadata-rich filenames.
  3. Curating -- providing a heart/vote system so management can filter to the best media for reuse.
  4. 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

  1. Connects to Discord with GuildMessages and MessageContent intents.
  2. Listens for messageCreate events in real time.
  3. For each message with attachments, checks file extension against known media types.
  4. Downloads the file to the NAS via admin_drive/media/.
  5. 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 SetExamplesBehaviour
Privatehr-confidential, incident-reports, performance-reviews, disciplinary, complaints, payrollSkipped entirely
Excludedbot-logs, bot-testing, dev-testing, bot-spamSkipped entirely
All othersgeneral, 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

ColumnTypeDescription
idSERIAL PKAuto-increment ID
discord_message_idTEXT NOT NULLDiscord message snowflake
discord_attachment_indexINT NOT NULL DEFAULT 0Position within multi-attachment messages
discord_channel_idTEXTDiscord channel snowflake
channel_nameTEXTHuman-readable channel name
author_idTEXTDiscord user snowflake
author_nameTEXTDiscord username at time of capture
original_urlTEXTOriginal Discord CDN URL (may expire)
file_pathTEXT NOT NULL UNIQUERelative path from admin_drive root
file_typeTEXT NOT NULLphoto or video
file_sizeBIGINTFile size in bytes
widthINTPixel width (photos/videos with metadata)
heightINTPixel height
mime_typeTEXTMIME type from Discord
captured_atTIMESTAMPTZ NOT NULLWhen the file was downloaded
message_created_atTIMESTAMPTZWhen the Discord message was posted
faces_processedBOOLEAN NOT NULL DEFAULT FALSEWhether facial recognition has run
face_countINTNumber of faces detected
metadataJSONB NOT NULL DEFAULT '{}'Flexible store for EXIF, face embeddings, tags
heart_countINT NOT NULL DEFAULT 0Cached count of hearts (votes)
has_super_heartBOOLEAN NOT NULL DEFAULT FALSECached flag: at least one super heart

Constraints:

  • UNIQUE(discord_message_id, discord_attachment_index) -- deduplication
  • CHECK (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/video
  • message_created_at DESC -- newest first
  • channel_name -- filter by source channel
  • author_name -- filter by who posted
  • faces_processed WHERE FALSE -- find unprocessed photos
  • heart_count DESC -- sort by popularity

media.media_hearts

ColumnTypeDescription
idSERIAL PKAuto-increment ID
media_idINT NOT NULL FKReferences discord_media(id) CASCADE
user_idUUID NOT NULL FKReferences accounts.users(id) CASCADE
is_super_heartBOOLEAN NOT NULL DEFAULT FALSEWhether this is a privileged super heart
created_atTIMESTAMPTZ NOT NULLWhen 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:

TierFilter RuleUse Case
AllNo filterBrowse everything
Goodheart_count >= 2Community favourites
Bestheart_count >= 4 OR has_super_heart = TRUENewsletter/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.


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, or both (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. 2026
  • month -- e.g. 03-mar
  • type -- photo, video, or both

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.).


Files:

  • admin/src/html/page_gallery.html
  • admin/src/js/pages/page_gallery.js

Layout

The gallery page has three main areas:

  1. 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
  2. Header bar -- Breadcrumbs showing current location, plus a global toggle for Photos / Videos / Both (default Both)

  3. 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.

For best recognition accuracy, capture 5-8 photos per person:

ShotPurpose
Face-on (neutral)Baseline embedding
Slight left (~30 degrees)Profile angle coverage
Slight right (~30 degrees)Profile angle coverage
Smiling / laughingFace shape variation
With glasses onAccessory variation
With glasses offAccessory variation
Outdoor lightingLighting variation
Indoor lightingLighting 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:

  1. Background worker scans media.discord_media for rows where faces_processed = FALSE
  2. Loads the image, runs face detection to find bounding boxes
  3. For each face, generates a 512-dim ArcFace embedding
  4. Compares embedding against known reference embeddings (from bookface library) using Euclidean distance
  5. If distance < threshold (~0.6), tags the face with the person's identity
  6. Stores all embeddings and tags in metadata.faces[] JSONB
  7. Updates face_count and sets faces_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 authentication
  • DB2_HOST, DB2_NAME, DB2_USER, DB2_PASSWORD, DB2_PORT -- PostgreSQL connection

App Settings

KeyTypeDescription
gallery_super_heart_user_idsJSON array of UUIDsUsers authorised to give super hearts

Managed via Admin App Settings > Gallery & Media.