43 KiB
Video Library Management
Overview
The Video Library system provides comprehensive video asset management through a dedicated Fastify microservice running on port 4100, separate from the main Express API. This dual API architecture allows the media system to operate independently while sharing the same PostgreSQL database.
Key Features:
- Dual API Architecture — Fastify media API (port 4100) separate from Express API (port 4000)
- Drizzle ORM — Media tables use Drizzle ORM instead of Prisma for schema flexibility
- 9 Directory Types — Organized library structure (studios, gifs, private, inbox, curated, playback, compilations, videos, highlights)
- FFprobe Integration — Automatic metadata extraction (duration, dimensions, orientation, quality, audio detection)
- Video CRUD — Full create, read, update, delete operations (admin-only)
- Directory Scanning — Bulk import videos from filesystem with automatic record creation
- Validation System — Re-validate videos to refresh metadata and check file integrity
- File Hashing — Duplicate detection via SHA-256 file hashing
- Soft Delete — Videos marked invalid instead of hard deletion (preserves history)
- Thumbnail Support — Custom thumbnail paths for video previews
Access Control:
- All video library operations require
SUPER_ADMINrole - Public video viewing handled separately via Shared Media system (see
public-gallery.md)
Technology Stack:
- Fastify 4.x — High-performance Node.js web framework
- Drizzle ORM — TypeScript-first ORM with zero-runtime overhead
- FFprobe — FFmpeg's media file analyzer for metadata extraction
- PostgreSQL 16 — Shared database with main API
Architecture
The Media API operates as an independent microservice while maintaining data consistency through shared database access:
flowchart TB
subgraph "Client Layer"
Admin[Admin GUI :3000]
Public[Public Users]
end
subgraph "API Layer"
Express[Express API :4000<br/>Prisma ORM]
Fastify[Fastify Media API :4100<br/>Drizzle ORM]
end
subgraph "Data Layer"
DB[(PostgreSQL 16<br/>v2_changemaker)]
FS[/media/local/library/<br/>Video Files]
end
subgraph "Processing"
FFprobe[FFprobe Service<br/>Metadata Extraction]
end
Admin -->|Media Requests| Fastify
Admin -->|Other Requests| Express
Public -->|View Videos| Fastify
Fastify -->|Drizzle Queries| DB
Express -->|Prisma Queries| DB
Fastify -->|Read/Write| FS
Fastify -->|Extract Metadata| FFprobe
FFprobe -->|Analyze| FS
style Fastify fill:#e74c3c
style Express fill:#3498db
style DB fill:#2ecc71
style FS fill:#f39c12
Architecture Highlights:
- Port Separation — Media API on 4100, Main API on 4000
- ORM Independence — Drizzle for media, Prisma for everything else
- Shared Database — Both APIs access same PostgreSQL instance
- File System Access — Media API has direct volume mount to
/media/local/library - Nginx Routing —
media.cmlite.orgroutes to port 4100
Why Dual API?
The media system was added after V2 launch as a self-contained enhancement. Keeping it as a separate Fastify microservice:
- Avoids disrupting the stable Express API
- Allows independent scaling and deployment
- Provides testing ground for Drizzle ORM migration
- Isolates video processing workloads from core application logic
Database Model (Drizzle)
Videos Table Schema
// api/src/modules/media/db/schema.ts
import { pgTable, uuid, text, integer, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
export const videos = pgTable('videos', {
id: uuid('id').primaryKey().defaultRandom(),
// File Information
path: text('path').notNull().unique(), // Relative path from library root
filename: text('filename').notNull(),
originalFilename: text('original_filename'), // User-uploaded filename
directoryType: text('directory_type').notNull(), // studios|gifs|private|inbox|curated|playback|compilations|videos|highlights
// Metadata
producer: text('producer'),
creator: text('creator'),
title: text('title'),
tags: jsonb('tags').$type<string[]>().default([]),
// Video Properties
durationSeconds: integer('duration_seconds'),
quality: text('quality'), // SD|HD|FHD|UHD
orientation: text('orientation'), // portrait|landscape|square
hasAudio: boolean('has_audio').default(false),
width: integer('width'),
height: integer('height'),
// File Details
fileSize: integer('file_size'), // Bytes
fileHash: text('file_hash'), // SHA-256 for duplicate detection
// Validation
isValid: boolean('is_valid').default(true),
lastValidated: timestamp('last_validated'),
standardizedAt: timestamp('standardized_at'), // When file was moved to standard location
// Thumbnail
thumbnailPath: text('thumbnail_path'),
// Public Sharing
publicViewCount: integer('public_view_count').default(0),
publicUpvoteCount: integer('public_upvote_count').default(0),
movedFromPublicAt: timestamp('moved_from_public_at'), // When video was unlocked from public
// Timestamps
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
Directory Types Enum
| Directory Type | Purpose | Public Eligible |
|---|---|---|
studios |
Studio-organized content | ✅ |
gifs |
Short looping videos | ✅ |
private |
Private/unreleased content | ❌ |
inbox |
Upload staging area | ❌ |
curated |
Hand-picked highlights | ✅ |
playback |
Playback-optimized encodes | ✅ |
compilations |
Multi-video compilations | ✅ |
videos |
General video library | ✅ |
highlights |
Auto-generated highlights | ✅ |
Quality Classifications
| Quality | Height Range | Typical Resolution |
|---|---|---|
SD |
< 720px | 480p, 576p |
HD |
720px - 1079px | 720p |
FHD |
1080px - 2159px | 1080p |
UHD |
≥ 2160px | 4K, 8K |
Orientation Detection
const detectOrientation = (width: number, height: number): string => {
const ratio = width / height;
if (ratio > 1.1) return 'landscape';
if (ratio < 0.9) return 'portrait';
return 'square';
};
API Endpoints
All endpoints require authentication with SUPER_ADMIN role unless marked as public.
List Videos
GET /api/media/videos
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
number | 1 | Page number for pagination |
limit |
number | 20 | Results per page (max 100) |
directoryType |
string | - | Filter by directory (studios, gifs, etc.) |
orientation |
string | - | Filter by orientation (portrait, landscape, square) |
producer |
string | - | Filter by producer (partial match) |
creator |
string | - | Filter by creator (partial match) |
quality |
string | - | Filter by quality (SD, HD, FHD, UHD) |
hasAudio |
boolean | - | Filter by audio presence |
isValid |
boolean | true | Filter by validation status |
search |
string | - | Search in title, producer, creator |
Response:
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"path": "videos/sample.mp4",
"filename": "sample.mp4",
"directoryType": "videos",
"producer": "Studio A",
"creator": "Director B",
"title": "Sample Video",
"durationSeconds": 180,
"quality": "FHD",
"orientation": "landscape",
"hasAudio": true,
"width": 1920,
"height": 1080,
"fileSize": 52428800,
"isValid": true,
"createdAt": "2026-02-10T12:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 156,
"totalPages": 8
}
}
Get Video Details
GET /api/media/videos/:id
Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"path": "videos/sample.mp4",
"filename": "sample.mp4",
"originalFilename": "my-video.mp4",
"directoryType": "videos",
"producer": "Studio A",
"creator": "Director B",
"title": "Sample Video",
"tags": ["action", "sports", "highlight"],
"durationSeconds": 180,
"quality": "FHD",
"orientation": "landscape",
"hasAudio": true,
"width": 1920,
"height": 1080,
"fileSize": 52428800,
"fileHash": "a3d2f1e8b9c7...",
"isValid": true,
"lastValidated": "2026-02-10T12:00:00Z",
"thumbnailPath": "thumbnails/550e8400.jpg",
"publicViewCount": 1250,
"publicUpvoteCount": 85,
"createdAt": "2026-02-10T12:00:00Z",
"updatedAt": "2026-02-10T12:00:00Z"
}
Create Video Record
POST /api/media/videos
Request Body:
{
"path": "videos/new-video.mp4",
"filename": "new-video.mp4",
"directoryType": "videos",
"producer": "Studio A",
"creator": "Director B",
"title": "New Video",
"tags": ["action", "sports"]
}
Notes:
- File must already exist at specified path on filesystem
- FFprobe metadata extraction runs automatically after creation
- Use
/api/media/upload/singlefor file upload + record creation
Response:
{
"id": "660e8400-e29b-41d4-a716-446655440000",
"path": "videos/new-video.mp4",
"filename": "new-video.mp4",
"directoryType": "videos",
"isValid": true,
"createdAt": "2026-02-13T10:30:00Z"
}
Update Video Metadata
PUT /api/media/videos/:id
Request Body:
{
"producer": "Updated Studio",
"creator": "New Director",
"title": "Updated Title",
"tags": ["updated", "tags"]
}
Updatable Fields:
producer— Video producer/studiocreator— Director/creator nametitle— Display titletags— Array of tag stringsthumbnailPath— Custom thumbnail path
Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"producer": "Updated Studio",
"creator": "New Director",
"title": "Updated Title",
"tags": ["updated", "tags"],
"updatedAt": "2026-02-13T10:35:00Z"
}
Delete Video
DELETE /api/media/videos/:id
Behavior:
- Soft Delete — Sets
isValid = falseinstead of removing record - File remains on filesystem (manual cleanup required)
- Video no longer appears in default listings
- Can be restored by setting
isValid = truevia database
Response:
{
"success": true,
"message": "Video marked as invalid"
}
Scan Directory
POST /api/media/videos/scan
Request Body:
{
"directoryType": "videos",
"skipExisting": true
}
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
directoryType |
string | ✅ | Directory to scan (videos, studios, etc.) |
skipExisting |
boolean | - | Skip files already in database (default: true) |
Process:
- Reads filesystem directory
/media/local/library/{directoryType}/ - Filters for video extensions (
.mp4,.mov,.avi,.mkv,.webm,.m4v,.flv) - Checks each file against database (by path)
- Creates records for new files
- Runs FFprobe metadata extraction on new records
Response:
{
"scanned": 45,
"created": 12,
"skipped": 33,
"failed": 0,
"errors": []
}
Validate Video
POST /api/media/videos/:id/validate
Purpose:
- Re-run FFprobe metadata extraction
- Update video properties (duration, dimensions, etc.)
- Verify file still exists and is readable
- Refresh file size and hash
Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"isValid": true,
"lastValidated": "2026-02-13T10:40:00Z",
"metadata": {
"durationSeconds": 180,
"width": 1920,
"height": 1080,
"quality": "FHD",
"orientation": "landscape",
"hasAudio": true
}
}
Configuration
Environment Variables
# Media API Server
MEDIA_API_PORT=4100
MEDIA_API_HOST=0.0.0.0
# File Paths
MEDIA_LIBRARY_PATH=/media/local/library
MEDIA_INBOX_PATH=/media/local/inbox
# Feature Flags
ENABLE_MEDIA_FEATURES=true
# Database (shared with main API)
DATABASE_URL=postgresql://user:pass@v2-postgres:5432/v2_changemaker
# FFprobe
FFPROBE_TIMEOUT=30000 # milliseconds
FFPROBE_PATH=/usr/bin/ffprobe # Auto-detected if not set
Docker Volume Mounts
# docker-compose.yml
services:
media-api:
volumes:
- /media/local/library:/media/local/library:ro # Read-only library
- /media/local/inbox:/media/local/inbox:rw # Read-write inbox
Important: Inbox requires :rw (read-write) for uploads. Library can be :ro (read-only) for security.
Site Settings
The media system respects the global ENABLE_MEDIA_FEATURES flag in Site Settings:
SELECT * FROM settings WHERE key = 'ENABLE_MEDIA_FEATURES';
When disabled:
- Media API still runs but returns 503 Service Unavailable
- Admin GUI hides Media menu items
- Public gallery shows maintenance message
Admin Workflow
Viewing the Video Library
- Navigate to Media → Library in admin sidebar
- Table displays all videos with:
- Thumbnail preview
- Title, producer, creator
- Duration, quality, orientation
- Directory type
- File size
- Created date
- Use filters at top:
- Directory Type dropdown
- Orientation radio buttons (All / Portrait / Landscape / Square)
- Quality checkboxes (SD, HD, FHD, UHD)
- Search input (searches title, producer, creator)
Scanning a Directory
When to Use:
- After manually copying videos to library directory
- After video processing jobs complete
- When videos exist on filesystem but not in database
Steps:
- Click "Scan Directory" button in Library page toolbar
- Select directory type from dropdown
- Toggle "Skip Existing" (recommended for large libraries)
- Click "Start Scan"
- Progress modal shows:
- Files scanned
- New records created
- Skipped (already in DB)
- Failed (with error messages)
- Click "Close" when complete
- Table refreshes with new videos
Example Output:
Scanning /media/local/library/videos...
Found 45 video files
- Created 12 new records
- Skipped 33 existing records
- Failed 0 files
Scan complete in 8.3 seconds
Editing Video Metadata
- Click pencil icon in video row
- Edit modal opens with fields:
- Producer — Studio or production company
- Creator — Director or primary creator
- Title — Display title
- Tags — Comma-separated tags (auto-suggests existing tags)
- Click "Save" to update
- Metadata changes immediately visible in table
Bulk Editing:
- Select multiple videos using checkboxes
- Click "Bulk Edit" button
- Set common fields (producer, tags, etc.)
- Click "Apply to Selected"
Validating Videos
Purpose: Refresh metadata and verify file integrity
Steps:
- Click "Validate" button in video row (or Actions dropdown)
- FFprobe re-analyzes video file
- Database updates with fresh metadata:
- Duration (may have changed if file was re-encoded)
- Dimensions
- Audio detection
- File size and hash
lastValidatedtimestamp updates- If file missing or corrupt,
isValidset tofalse
Bulk Validation:
- Select multiple videos
- Click "Validate Selected"
- Progress modal shows validation results
- Failed validations highlighted in red
Deleting Videos
Soft Delete (Default):
- Click trash icon in video row
- Confirm deletion dialog
- Video marked
isValid = false - Video disappears from default view
- File remains on filesystem
- Record preserved in database
Viewing Deleted Videos:
- Toggle "Show Invalid" filter
- Deleted videos appear with strikethrough
- Can restore by clicking "Restore" button
Hard Delete (Database Only):
- Filter for invalid videos
- Select video(s)
- Click "Permanently Delete"
- Removes database record
- File still on filesystem (manual cleanup required)
File System Cleanup:
Deleted video files must be manually removed from filesystem:
# SSH into media-api container
docker compose exec media-api sh
# Navigate to library
cd /media/local/library/videos
# Remove specific file
rm deleted-video.mp4
# Or find and remove all invalid videos (BE CAREFUL)
# (requires database query to get invalid file paths)
Directory Structure
/media/local/library/
├── studios/ # Studio-organized content
│ ├── studio-a/
│ │ ├── video-001.mp4
│ │ └── video-002.mp4
│ └── studio-b/
│ └── video-003.mp4
│
├── gifs/ # Short looping videos
│ ├── loop-001.mp4
│ └── loop-002.webm
│
├── private/ # Private/unreleased content
│ └── unreleased.mp4
│
├── inbox/ # Upload staging area (READ-WRITE)
│ ├── uuid-123.mp4 # Temp uploads
│ └── uuid-456.mov
│
├── curated/ # Hand-picked highlights
│ ├── best-of-2025.mp4
│ └── top-plays.mp4
│
├── playback/ # Playback-optimized encodes
│ ├── streaming-001.mp4
│ └── streaming-002.mp4
│
├── compilations/ # Multi-video compilations
│ ├── compilation-001.mp4
│ └── mega-compilation.mp4
│
├── videos/ # General video library
│ ├── video-001.mp4
│ ├── video-002.mp4
│ └── ... (thousands of videos)
│
└── highlights/ # Auto-generated highlights
├── highlight-001.mp4
└── highlight-002.mp4
Directory Guidelines:
- studios/ — Organize by producer/studio name (subfolder structure allowed)
- gifs/ — Short videos under 15 seconds, suitable for looping
- private/ — Never shared publicly, admin-only access
- inbox/ — Temporary upload location, files moved after processing
- curated/ — High-quality selections for public gallery homepage
- playback/ — Web-optimized encodes (H.264, web-friendly profiles)
- compilations/ — Merged videos created by compilation jobs
- videos/ — Main library, all-purpose storage
- highlights/ — AI-generated or manually created highlight reels
Code Examples
List Videos with Filters (Fastify Route)
// api/src/modules/media/routes/videos.routes.ts
import { FastifyInstance } from 'fastify';
import { eq, and, like, desc, sql } from 'drizzle-orm';
import { videos } from '@/modules/media/db/schema';
import { db } from '@/modules/media/db';
export default async function (app: FastifyInstance) {
app.get('/api/media/videos', async (req, reply) => {
const {
page = 1,
limit = 20,
directoryType,
orientation,
producer,
creator,
quality,
hasAudio,
isValid = true,
search,
} = req.query as any;
// Build filters
const filters = [];
if (directoryType) {
filters.push(eq(videos.directoryType, directoryType));
}
if (orientation) {
filters.push(eq(videos.orientation, orientation));
}
if (producer) {
filters.push(like(videos.producer, `%${producer}%`));
}
if (creator) {
filters.push(like(videos.creator, `%${creator}%`));
}
if (quality) {
filters.push(eq(videos.quality, quality));
}
if (typeof hasAudio === 'boolean') {
filters.push(eq(videos.hasAudio, hasAudio));
}
if (typeof isValid === 'boolean') {
filters.push(eq(videos.isValid, isValid));
}
if (search) {
filters.push(
sql`(
${videos.title} ILIKE ${'%' + search + '%'} OR
${videos.producer} ILIKE ${'%' + search + '%'} OR
${videos.creator} ILIKE ${'%' + search + '%'}
)`
);
}
// Count total
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(videos)
.where(and(...filters));
// Fetch paginated results
const results = await db
.select()
.from(videos)
.where(and(...filters))
.limit(Number(limit))
.offset((Number(page) - 1) * Number(limit))
.orderBy(desc(videos.createdAt));
reply.send({
data: results,
pagination: {
page: Number(page),
limit: Number(limit),
total: Number(count),
totalPages: Math.ceil(Number(count) / Number(limit)),
},
});
});
}
Scan Directory for Videos
// api/src/modules/media/routes/videos.routes.ts
import fs from 'fs/promises';
import path from 'path';
import { eq } from 'drizzle-orm';
import { videos } from '@/modules/media/db/schema';
import { ffprobeService } from '@/modules/media/services/ffprobe.service';
app.post('/api/media/videos/scan', async (req, reply) => {
const { directoryType, skipExisting = true } = req.body as any;
if (!directoryType) {
return reply.code(400).send({ error: 'directoryType required' });
}
const dirPath = path.join(process.env.MEDIA_LIBRARY_PATH!, directoryType);
try {
// Check directory exists
await fs.access(dirPath);
} catch {
return reply.code(400).send({ error: `Directory not found: ${directoryType}` });
}
// Read directory
const files = await fs.readdir(dirPath, { recursive: true });
// Filter for video files
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];
const videoFiles = files.filter((f) =>
videoExtensions.some((ext) => f.toLowerCase().endsWith(ext))
);
const results = {
scanned: videoFiles.length,
created: 0,
skipped: 0,
failed: 0,
errors: [] as string[],
};
for (const filename of videoFiles) {
try {
const relativePath = path.join(directoryType, filename);
// Check if already exists
if (skipExisting) {
const existing = await db
.select()
.from(videos)
.where(eq(videos.path, relativePath))
.limit(1);
if (existing.length > 0) {
results.skipped++;
continue;
}
}
// Extract metadata
const fullPath = path.join(dirPath, filename);
const metadata = await ffprobeService.extract(fullPath);
// Create record
await db.insert(videos).values({
path: relativePath,
filename: path.basename(filename),
directoryType,
durationSeconds: metadata.duration,
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
quality: metadata.quality,
hasAudio: metadata.hasAudio,
fileSize: metadata.fileSize,
isValid: true,
});
results.created++;
} catch (error: any) {
results.failed++;
results.errors.push(`${filename}: ${error.message}`);
}
}
reply.send(results);
});
Validate Video Metadata
// api/src/modules/media/routes/videos.routes.ts
import { eq } from 'drizzle-orm';
import { videos } from '@/modules/media/db/schema';
import { ffprobeService } from '@/modules/media/services/ffprobe.service';
app.post('/api/media/videos/:id/validate', async (req, reply) => {
const { id } = req.params as { id: string };
// Fetch video record
const [video] = await db
.select()
.from(videos)
.where(eq(videos.id, id))
.limit(1);
if (!video) {
return reply.code(404).send({ error: 'Video not found' });
}
try {
// Build full file path
const fullPath = path.join(process.env.MEDIA_LIBRARY_PATH!, video.path);
// Extract fresh metadata
const metadata = await ffprobeService.extract(fullPath);
// Update database
const [updated] = await db
.update(videos)
.set({
durationSeconds: metadata.duration,
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
quality: metadata.quality,
hasAudio: metadata.hasAudio,
fileSize: metadata.fileSize,
fileHash: metadata.fileHash,
isValid: true,
lastValidated: new Date(),
updatedAt: new Date(),
})
.where(eq(videos.id, id))
.returning();
reply.send({
id: updated.id,
isValid: updated.isValid,
lastValidated: updated.lastValidated,
metadata: {
durationSeconds: updated.durationSeconds,
width: updated.width,
height: updated.height,
quality: updated.quality,
orientation: updated.orientation,
hasAudio: updated.hasAudio,
},
});
} catch (error: any) {
// Mark as invalid if validation fails
await db
.update(videos)
.set({
isValid: false,
lastValidated: new Date(),
updatedAt: new Date(),
})
.where(eq(videos.id, id));
reply.code(500).send({
error: 'Validation failed',
message: error.message,
isValid: false,
});
}
});
Frontend: Library Page Table
// admin/src/pages/media/LibraryPage.tsx
import { Table, Button, Select, Input, Tag, Space } from 'antd';
import { useEffect, useState } from 'react';
import { mediaApi } from '@/lib/media-api';
export default function LibraryPage() {
const [videos, setVideos] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [filters, setFilters] = useState({
directoryType: undefined,
orientation: undefined,
search: '',
});
const fetchVideos = async () => {
setLoading(true);
try {
const { data } = await mediaApi.get('/api/media/videos', {
params: {
page: pagination.page,
limit: pagination.limit,
...filters,
},
});
setVideos(data.data);
setPagination((prev) => ({ ...prev, total: data.pagination.total }));
} catch (error) {
console.error('Failed to fetch videos:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchVideos();
}, [pagination.page, filters]);
const columns = [
{
title: 'Preview',
dataIndex: 'thumbnailPath',
width: 100,
render: (path: string) => (
<img
src={path || '/placeholder.jpg'}
alt="Thumbnail"
style={{ width: 80, height: 60, objectFit: 'cover' }}
/>
),
},
{
title: 'Title',
dataIndex: 'title',
render: (text: string, record: any) => (
<div>
<div style={{ fontWeight: 600 }}>{text || record.filename}</div>
<div style={{ fontSize: 12, color: '#888' }}>
{record.producer} • {record.creator}
</div>
</div>
),
},
{
title: 'Duration',
dataIndex: 'durationSeconds',
width: 100,
render: (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
},
},
{
title: 'Quality',
dataIndex: 'quality',
width: 80,
render: (quality: string) => {
const colors: Record<string, string> = {
SD: 'default',
HD: 'blue',
FHD: 'green',
UHD: 'purple',
};
return <Tag color={colors[quality]}>{quality}</Tag>;
},
},
{
title: 'Orientation',
dataIndex: 'orientation',
width: 100,
},
{
title: 'Directory',
dataIndex: 'directoryType',
width: 120,
},
{
title: 'Actions',
width: 150,
render: (_: any, record: any) => (
<Space>
<Button size="small" onClick={() => handleEdit(record.id)}>
Edit
</Button>
<Button size="small" onClick={() => handleValidate(record.id)}>
Validate
</Button>
<Button size="small" danger onClick={() => handleDelete(record.id)}>
Delete
</Button>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Select
placeholder="Directory Type"
style={{ width: 200 }}
onChange={(value) => setFilters({ ...filters, directoryType: value })}
allowClear
>
<Select.Option value="videos">Videos</Select.Option>
<Select.Option value="studios">Studios</Select.Option>
<Select.Option value="gifs">GIFs</Select.Option>
<Select.Option value="curated">Curated</Select.Option>
</Select>
<Input.Search
placeholder="Search title, producer, creator"
style={{ width: 300 }}
onSearch={(value) => setFilters({ ...filters, search: value })}
allowClear
/>
<Button type="primary" onClick={handleScanDirectory}>
Scan Directory
</Button>
</Space>
<Table
columns={columns}
dataSource={videos}
loading={loading}
rowKey="id"
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
onChange: (page) => setPagination({ ...pagination, page }),
}}
/>
</div>
);
}
Troubleshooting
Problem: Media API Not Accessible
Symptoms:
- Admin GUI shows "Cannot connect to media API"
- Browser console shows CORS errors or network failures
- Public gallery doesn't load
Solutions:
- Check Fastify server running:
docker compose ps media-api
# Should show "Up" status
docker compose logs media-api
# Look for "Fastify server listening on port 4100"
- Verify port 4100 not in use:
lsof -i :4100
# Should show only media-api container
# If another process using port, stop it or change MEDIA_API_PORT in .env
- Check nginx proxy configuration:
# nginx/conf.d/api.conf
# Media API block must come BEFORE general API block
server {
listen 80;
server_name media.cmlite.org;
location / {
proxy_pass http://localhost:4100;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
- Test direct API access:
# From host machine
curl http://localhost:4100/api/media/videos
# From inside container
docker compose exec media-api curl http://localhost:4100/api/media/videos
- Check Docker networking:
docker network inspect changemaker-lite
# Verify media-api container connected
Problem: Scan Finds No Videos
Symptoms:
- Scan completes with "Created 0 new records"
- Directory known to contain video files
- Scan reports 0 files scanned
Solutions:
- Verify MEDIA_LIBRARY_PATH correct:
# Check environment variable
docker compose exec media-api printenv MEDIA_LIBRARY_PATH
# Should output: /media/local/library
# List directory contents
docker compose exec media-api ls -la /media/local/library/videos
# Should show video files
- Check directory exists:
# Create missing directory
docker compose exec media-api mkdir -p /media/local/library/videos
# Copy test videos
docker cp test.mp4 $(docker compose ps -q media-api):/media/local/library/videos/
- Verify Docker volume mounted:
# docker-compose.yml
services:
media-api:
volumes:
- /media/local/library:/media/local/library:ro # Check path correct
# Inspect volume mounts
docker compose config | grep -A 5 media-api
- Check file extensions supported:
Only these extensions scanned:
.mp4.mov.avi.mkv.webm.m4v.flv
Rename files if using other extensions:
# Rename .MP4 to .mp4 (case-sensitive)
docker compose exec media-api sh -c 'cd /media/local/library/videos && rename "s/.MP4$/.mp4/" *.MP4'
- Check file permissions:
# Verify readable by container user
docker compose exec media-api ls -la /media/local/library/videos
# Fix permissions if needed (on host)
sudo chmod -R 755 /media/local/library
Problem: FFprobe Validation Fails
Symptoms:
- Validation returns error "FFprobe command failed"
- Videos marked
isValid = false - Timeout errors after 30 seconds
Solutions:
- Check FFmpeg installed in container:
# Verify FFprobe available
docker compose exec media-api which ffprobe
# Should output: /usr/bin/ffprobe
docker compose exec media-api ffprobe -version
# Should show FFmpeg version info
- Install FFmpeg if missing:
# api/Dockerfile.media
FROM node:20-alpine
# Install FFmpeg (both dev and production stages)
RUN apk add --no-cache ffmpeg
# ... rest of Dockerfile
# Rebuild container
docker compose build media-api
docker compose up -d media-api
- Test FFprobe directly on video:
# Run FFprobe manually
docker compose exec media-api ffprobe -v quiet -print_format json -show_streams -show_format /media/local/library/videos/test.mp4
# If this fails, video file corrupt or unsupported
- Check timeout not exceeded:
Default timeout: 30 seconds
# For very large files (>5GB), increase timeout
# api/src/modules/media/services/ffprobe.service.ts
const FFPROBE_TIMEOUT = 60000; // 60 seconds
- Verify video file not corrupt:
# Test playback
docker compose exec media-api ffplay /media/local/library/videos/test.mp4
# Or copy to host and test in VLC
docker cp $(docker compose ps -q media-api):/media/local/library/videos/test.mp4 ./test.mp4
vlc test.mp4
- Check for special characters in filename:
# Rename files with spaces or special chars
docker compose exec media-api sh -c 'cd /media/local/library/videos && rename "s/ /_/g" *.mp4'
Problem: Drizzle Schema Changes Not Applied
Symptoms:
- Code references new column but database doesn't have it
- Error: "column does not exist"
- Schema changes made but not reflected
Solutions:
- Push schema changes:
# Drizzle uses push (not migrations)
cd api
npx drizzle-kit push
# Confirm changes
- Verify connection:
# Check DATABASE_URL correct
docker compose exec media-api printenv DATABASE_URL
# Test connection
docker compose exec media-api npx drizzle-kit studio
# Opens DB browser on http://localhost:4983
- Compare with Prisma migrations:
Media tables exist in same database as Prisma tables. If conflict:
# Check both schemas
npx prisma db pull # Prisma introspection
npx drizzle-kit introspect # Drizzle introspection
# Resolve conflicts manually
Problem: Large Library Performance
Symptoms:
- Library page loads slowly (5+ seconds)
- Pagination sluggish
- Scan operations timeout
Solutions:
- Add database indexes:
-- Index for common filters
CREATE INDEX idx_videos_directory_type ON videos(directory_type);
CREATE INDEX idx_videos_orientation ON videos(orientation);
CREATE INDEX idx_videos_quality ON videos(quality);
CREATE INDEX idx_videos_is_valid ON videos(is_valid);
CREATE INDEX idx_videos_created_at ON videos(created_at DESC);
-- Composite index for filtered queries
CREATE INDEX idx_videos_filters ON videos(directory_type, is_valid, created_at DESC);
-- Full-text search index
CREATE INDEX idx_videos_search ON videos USING gin(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(producer, '') || ' ' || coalesce(creator, '')));
- Reduce page size:
// admin/src/pages/media/LibraryPage.tsx
const [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0 });
// Reduced from 20 to 10
- Enable query caching:
// api/src/modules/media/routes/videos.routes.ts
import { redisClient } from '@/config/redis';
app.get('/api/media/videos', async (req, reply) => {
const cacheKey = `videos:list:${JSON.stringify(req.query)}`;
// Check cache
const cached = await redisClient.get(cacheKey);
if (cached) {
return reply.send(JSON.parse(cached));
}
// Fetch from database
const results = await db.select()...;
// Cache for 5 minutes
await redisClient.setex(cacheKey, 300, JSON.stringify(results));
reply.send(results);
});
- Use virtual scrolling:
// Replace Ant Design Table with react-window for large datasets
import { FixedSizeList } from 'react-window';
Performance Considerations
Directory Scans
Scaling Factors:
- 100 files: ~2 seconds
- 1,000 files: ~15 seconds
- 10,000 files: ~2.5 minutes
Optimization Strategies:
- Incremental Scans — Use
skipExisting: trueto only process new files - Parallel Processing — Scan multiple directories simultaneously
- Background Jobs — Queue scans as async jobs instead of synchronous requests
- Caching — Cache directory listings in Redis
FFprobe Extraction
Timing:
- Small video (<100MB): ~50-100ms
- Medium video (500MB): ~150-250ms
- Large video (2GB+): ~500ms-1s
Batch Processing:
For 100 videos: ~10-20 seconds total
Optimization:
// Parallel extraction (limit concurrency)
import pLimit from 'p-limit';
const limit = pLimit(5); // Max 5 concurrent FFprobe calls
const results = await Promise.all(
videoFiles.map((file) =>
limit(() => ffprobeService.extract(file))
)
);
Database Queries
Query Performance:
- List 20 videos (no filters): ~5-10ms
- List 20 videos (with filters): ~10-20ms
- Full-text search: ~20-50ms
- Count total videos: ~5ms (with index)
Optimization:
- Always use pagination — Never fetch all records
- Index heavily filtered columns — directoryType, orientation, quality, isValid
- Use SELECT only needed columns — Avoid
SELECT *for large tables - Cache counts — Total video count changes infrequently, cache in Redis
Thumbnail Generation
Deferred Loading:
Don't generate thumbnails during scan. Instead:
- Create video record without thumbnail
- Queue thumbnail generation job
- Worker processes job asynchronously
- Update record with
thumbnailPath
Lazy Loading:
Frontend requests thumbnails only when visible (IntersectionObserver).
Dual API Architecture
Why Separate Fastify API?
The media system was introduced as a Phase 14 enhancement after V2 core functionality stabilized. A separate Fastify microservice was chosen to:
- Avoid Disrupting Stable Express API — V2 Express API battle-tested with 30+ models, introducing media directly risked regressions
- Test Drizzle ORM Migration — Fastify+Drizzle serves as proof-of-concept for potential future Prisma→Drizzle migration
- Isolate Video Processing — CPU/GPU-intensive FFprobe, encoding jobs isolated from main API request handling
- Independent Scaling — Media API can be horizontally scaled separately based on video processing load
- Technology Experimentation — Fastify's performance benefits evaluated for potential broader adoption
Database Sharing Strategy
Same PostgreSQL, Different ORMs:
┌─────────────────┐
│ PostgreSQL 16 │
│ v2_changemaker │
└─────────────────┘
↑
┌────┴────┐
│ │
┌───┴───┐ ┌──┴────┐
│Prisma │ │Drizzle│
│ ORM │ │ ORM │
└───┬───┘ └──┬────┘
│ │
┌───┴────┐ ┌─┴─────┐
│Express │ │Fastify│
│ API │ │ Media │
│ :4000 │ │ API │
│ │ │ :4100 │
└────────┘ └───────┘
Benefits:
- Single Source of Truth — All data in one database
- Cross-API Queries — Main API can query media tables via Prisma raw queries
- Unified Backups — One PostgreSQL dump includes both APIs
- Shared Connections — Connection pooling optimizations benefit both
Challenges:
- Schema Coordination — Must manually sync schema changes between Prisma migrations and Drizzle pushes
- Type Conflicts — Same table, different type definitions (Prisma vs Drizzle types)
- Migration Complexity — Prisma generates migrations, Drizzle uses push (no migration files)
Migration Strategy Roadmap
Short Term (Current):
- Keep dual API architecture
- Synchronize schemas manually
- Document shared tables in both ORMs
Medium Term (6-12 months):
- Evaluate Fastify+Drizzle performance vs Express+Prisma
- If Fastify superior, migrate select Express routes to Fastify
- If no significant benefit, consolidate media into Express+Prisma
Long Term (12+ months):
- Unified API (either all Express or all Fastify)
- Single ORM (either all Prisma or all Drizzle)
- Deprecate less performant stack
Migration Effort Estimate:
- Media to Express+Prisma: 3-5 days (convert Drizzle queries to Prisma, merge Fastify routes into Express)
- All to Fastify+Drizzle: 2-3 weeks (convert 30+ Prisma models to Drizzle, rewrite Express routes for Fastify)
Related Documentation
Backend Documentation
- API Server:
backend/api/media-server.md— Fastify server setup, middleware, error handling - Videos Module:
backend/modules/media/videos.md— Video routes, service layer, business logic - FFprobe Service:
backend/modules/media/ffprobe.md— Metadata extraction implementation - Jobs System:
backend/modules/media/jobs.md— Job queue architecture, worker processes
Frontend Documentation
- Library Page:
frontend/pages/media/library.md— Video library management UI - Shared Media Page:
frontend/pages/media/shared.md— Public gallery admin UI - Media Components:
frontend/components/media.md— Reusable video components
Database Documentation
- Media Models:
database/models/media.md— Drizzle schema definitions for videos, compilations, jobs - Drizzle Setup:
database/drizzle.md— Drizzle ORM configuration, connection management
Feature Documentation
- Video Upload:
features/media/upload.md— Upload system workflow, FFprobe integration - Media Jobs:
features/media/jobs.md— Job queue system, processing pipeline - Public Gallery:
features/media/public-gallery.md— Public video sharing system
Integration Documentation
- Dual API Architecture:
architecture/dual-api.md— Express+Prisma vs Fastify+Drizzle comparison - Nginx Routing:
deployment/nginx.md— Reverse proxy configuration for media.cmlite.org - Docker Setup:
deployment/docker.md— Media API container, volume mounts, healthchecks
Next Steps
After mastering video library management:
- Upload System — Read
features/media/upload.mdto understand video upload workflow - Jobs Queue — Review
features/media/jobs.mdfor video processing automation - Public Gallery — Explore
features/media/public-gallery.mdfor sharing videos publicly - Custom Integrations — Use Media API endpoints to build custom video features
For hands-on practice, try:
# 1. Upload test videos
curl -X POST http://localhost:4100/api/media/upload/single \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "video=@test.mp4" \
-F "producer=Test Studio" \
-F "title=Test Video"
# 2. Scan directory
curl -X POST http://localhost:4100/api/media/videos/scan \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"directoryType": "videos"}'
# 3. List videos
curl http://localhost:4100/api/media/videos?page=1&limit=10
# 4. Validate video
curl -X POST http://localhost:4100/api/media/videos/VIDEO_ID/validate \
-H "Authorization: Bearer YOUR_TOKEN"
Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team