30 KiB
Media Module (Fastify Video Library API)
Overview
The Media module is a separate Fastify microservice running on port 4100 (separate from the main Express API on port 4000). It provides a complete video library management system with public gallery features, reaction tracking, and job queue for video processing. The module uses Drizzle ORM (unlike the main API's Prisma ORM) and shares the same PostgreSQL database.
Key Features:
- Dual API architecture:
- Main Express API (port 4000) — Prisma ORM
- Media Fastify API (port 4100) — Drizzle ORM
- Shared PostgreSQL 16 database
- Video library management:
- Directory-based organization (studios, gifs, private, inbox, curated, etc.)
- Metadata tracking (duration, quality, orientation, file size, dimensions)
- Thumbnail generation and storage
- File hash-based deduplication
- Public gallery system:
- Category-based organization
- Engagement tracking (views, upvotes, comments, watch time)
- Lock/unlock system for controlling public visibility
- Session-based upvoting (no auth required)
- Reaction system:
- 6 emoji reactions (👍 like, ❤️ love, 😂 laugh, 😮 wow, 😢 sad, 😠 angry)
- Timestamped reactions (mark specific moments in videos)
- User-based tracking (authenticated users)
- Job queue:
- Video processing job management
- Resource category allocation (GPU AI, GPU encode, CPU)
- Queue position tracking with VRAM requirements
- Pipeline integration for multi-step processing
- Compilation management:
- Multi-video compilation tracking
- Settings preservation
- Feature flag:
ENABLE_MEDIA_FEATURES=true(opt-in)
File Paths
| File | Purpose |
|---|---|
api/src/media-server.ts |
Fastify server entry point (port 4100) |
api/src/modules/media/db/schema.ts |
Drizzle schema (15+ tables, 1,400+ lines) |
api/src/modules/media/routes/videos.routes.ts |
Video CRUD routes (99 lines) |
api/src/modules/media/routes/public-media.routes.ts |
Public gallery routes (12,852 lines) |
api/src/modules/media/routes/reactions.routes.ts |
Reaction routes (135 lines) |
api/src/modules/media/routes/comments.routes.ts |
Comment routes (4,827 lines) |
api/src/modules/media/middleware/auth.ts |
Fastify auth middleware (JWT verification) |
api/src/modules/media/types/enums.ts |
Shared enums |
Database Models (Drizzle ORM)
Videos Table
export const videos = pgTable('videos', {
id: serial('id').primaryKey(),
path: text('path').notNull().unique(),
filename: text('filename').notNull(),
producer: text('producer'),
creator: text('creator'),
title: text('title'),
durationSeconds: integer('duration_seconds'),
quality: text('quality'),
orientation: text('orientation'),
hasAudio: boolean('has_audio').default(true),
fileSize: bigint('file_size', { mode: 'number' }),
fileHash: text('file_hash'),
width: integer('width'),
height: integer('height'),
lastValidated: timestamp('last_validated', { withTimezone: true }),
isValid: boolean('is_valid').default(true),
thumbnailPath: text('thumbnail_path'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
tags: jsonb('tags').$type<string[]>(),
// Directory type for efficient filtering
directoryType: text('directory_type').$type<DirectoryType>(),
// Historical engagement stats (preserved when moved from public media)
publicViewCount: integer('public_view_count'),
publicUpvoteCount: integer('public_upvote_count'),
publicCommentCount: integer('public_comment_count'),
publicCompletionCount: integer('public_completion_count'),
publicTotalWatchTime: integer('public_total_watch_time'),
movedFromPublicAt: timestamp('moved_from_public_at', { withTimezone: true }),
// Name standardization tracking
originalFilename: text('original_filename'),
originalPath: text('original_path'),
standardizedAt: timestamp('standardized_at', { withTimezone: true }),
}, (table) => ({
orientationIdx: index('idx_orientation').on(table.orientation),
producerIdx: index('idx_producer').on(table.producer),
isValidIdx: index('idx_is_valid').on(table.isValid),
directoryTypeIdx: index('idx_directory_type').on(table.directoryType),
fingerprintIdx: index('idx_videos_fingerprint').on(
table.durationSeconds, table.fileSize, table.width, table.height
),
directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation').on(
table.directoryType, table.isValid, table.orientation
),
}));
// Directory types
export const DIRECTORY_TYPES = [
'studios', 'gifs', 'private', 'inbox', 'curated',
'playback', 'compilations', 'videos', 'highlights'
] as const;
export type DirectoryType = typeof DIRECTORY_TYPES[number];
Key Features:
- Unique path constraint — Prevents duplicate entries
- File hash — Enables deduplication based on content
- Fingerprint index — Fast duplicate detection (duration + fileSize + width + height)
- Directory type — Efficient filtering by category
- Historical stats — Preserves engagement metrics when moving from public gallery
- Standardization tracking — Tracks original filename before renaming
Public Media Table
export const publicMedia = pgTable('public_media', {
id: serial('id').primaryKey(),
path: text('path').notNull().unique(),
filename: text('filename').notNull(),
category: text('category').notNull(),
durationSeconds: integer('duration_seconds'),
quality: text('quality'),
orientation: text('orientation'),
thumbnailPath: text('thumbnail_path'),
fileSize: bigint('file_size', { mode: 'number' }),
// Denormalized counters for performance
viewCount: integer('view_count').default(0),
upvoteCount: integer('upvote_count').default(0),
commentCount: integer('comment_count').default(0),
finishCount: integer('finish_count').default(0),
totalWatchTime: integer('total_watch_time').default(0),
// Lock system
isLocked: boolean('is_locked').default(false),
lockedAt: timestamp('locked_at', { withTimezone: true }),
lockedReason: text('locked_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
categoryIdx: index('idx_public_media_category').on(table.category),
orientationIdx: index('idx_public_media_orientation').on(table.orientation),
viewCountIdx: index('idx_public_media_views').on(table.viewCount),
upvoteCountIdx: index('idx_public_media_upvotes').on(table.upvoteCount),
isLockedIdx: index('idx_public_media_locked').on(table.isLocked),
}));
Key Features:
- Denormalized counters — Fast sorting by popularity (no joins)
- Lock system — Admin can lock videos to prevent public access
- Category organization — Flexible categorization system
- Performance indexes — Optimized for sorting by views/upvotes
Upvotes Table
export const upvotes = pgTable('upvotes', {
id: serial('id').primaryKey(),
mediaId: integer('media_id').notNull(),
sessionId: text('session_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
uniqueVoteIdx: index('idx_upvotes_unique').on(table.mediaId, table.sessionId),
mediaIdx: index('idx_upvotes_media').on(table.mediaId),
}));
Key Features:
- Session-based — No authentication required (anonymous upvoting)
- Unique constraint — One upvote per session per media item
- Denormalized — upvoteCount in publicMedia table updated via trigger or application logic
Video Reactions Table
export const REACTION_TYPES = ['like', 'love', 'laugh', 'wow', 'sad', 'angry'] as const;
export type ReactionType = typeof REACTION_TYPES[number];
export const videoReactions = pgTable('video_reactions', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull(),
mediaId: integer('media_id').notNull(),
reactionType: text('reaction_type').notNull(),
videoTimestamp: integer('video_timestamp').notNull(), // seconds into video
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
}, (table) => ({
userMediaTypeIdx: index('idx_video_reactions_user_media_type').on(
table.userId, table.mediaId, table.reactionType
),
mediaTimestampIdx: index('idx_video_reactions_media_timestamp').on(
table.mediaId, table.videoTimestamp
),
mediaIdx: index('idx_video_reactions_media').on(table.mediaId),
createdAtIdx: index('idx_video_reactions_created').on(table.createdAt),
}));
Reaction Emojis:
| Type | Emoji | Label |
|---|---|---|
like |
👍 | Like |
love |
❤️ | Love |
laugh |
😂 | Laugh |
wow |
😮 | Wow |
sad |
😢 | Sad |
angry |
😠 | Angry |
Key Features:
- Timestamped reactions — Mark specific moments in videos
- User-based — Requires authentication
- Timeline visualization — Can show reaction heatmap across video timeline
Jobs Table
export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';
export type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
export const jobs = pgTable('jobs', {
id: serial('id').primaryKey(),
type: text('type').notNull(),
status: text('status').default('pending').$type<JobStatus>(),
progress: integer('progress').default(0),
log: text('log'),
params: jsonb('params').$type<Record<string, unknown>>(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
// Queue management
resourceCategory: text('resource_category').default('cpu').$type<ResourceCategory>(),
vramRequired: integer('vram_required').default(0),
queuePosition: integer('queue_position'),
waitingReason: text('waiting_reason'),
priority: integer('priority').default(5),
// Pipeline integration
pipelineId: integer('pipeline_id'),
pipelineStepId: integer('pipeline_step_id'),
}, (table) => ({
queueIdx: index('idx_jobs_queue').on(table.status, table.priority, table.createdAt),
resourceIdx: index('idx_jobs_resource').on(table.resourceCategory, table.status),
pipelineIdx: index('idx_jobs_pipeline').on(table.pipelineId),
}));
Job Types:
compilation— Multi-video compilationscan,public_scan— Video library scanningorganize,organize_studio— Automatic organizationreencode_streaming— Transcode for web streamingcompile_random,compile_quad,compile_quad_horizontal, etc. — Compilation variantsgenerate_gif,fetch,digest,clip_generate,highlight_generate— Content generationtag_generation,scene_extract,clip_extract_only,auto_organize_publish— AI-powered tasks
Resource Categories:
gpu_ai— AI/ML tasks (scene detection, tagging, etc.) — High VRAMgpu_encode— Video encoding/transcoding — Medium VRAMcpu— General processing — No GPU required
Compilations Table
export const compilations = pgTable('compilations', {
id: serial('id').primaryKey(),
filename: text('filename').notNull(),
path: text('path'),
durationSeconds: integer('duration_seconds'),
videoIds: jsonb('video_ids').$type<number[]>(),
settings: jsonb('settings').$type<Record<string, unknown>>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
Key Features:
- Multi-video tracking — Stores array of source video IDs
- Settings preservation — Stores compilation parameters (layout, transitions, etc.)
API Endpoints
Admin Endpoints (Videos)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/videos |
Admin roles | List videos with pagination |
| GET | /api/videos/:id |
Admin roles | Get single video |
| GET | /api/videos/health |
None | Health check |
Admin Roles: Requires admin role via Fastify auth middleware
Public Media Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/media/public |
None | List shared media (paginated, filterable, sorted) |
| GET | /api/media/public/:id |
None | Get single media + increment view count |
| POST | /api/media/public/:id/upvote |
None | Upvote media (session-based) |
| DELETE | /api/media/public/:id/upvote |
None | Remove upvote |
| POST | /api/media/public/:id/finish |
None | Mark video as finished |
| POST | /api/media/public/:id/watch-time |
None | Track watch time |
Reaction Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/reactions |
Required | Add reaction to video |
| GET | /api/reactions |
None | Get reactions (filterable by mediaId/userId) |
| GET | /api/reactions/config |
None | Get available reaction types |
Comment Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/media/comments |
Optional | Add comment (auth optional, session-based) |
| GET | /api/media/comments |
None | List comments for media |
Endpoint Details
GET /api/videos
List videos with pagination and search (admin only).
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
| limit | number | 50 | Results per page (max 100) |
| offset | number | 0 | Skip N results |
| search | string | - | Search title (case-insensitive) |
Example Request:
curl -H "Authorization: Bearer <token>" \
"http://localhost:4100/api/videos?limit=20&offset=0&search=demo"
Response (200 OK):
{
"videos": [
{
"id": 123,
"title": "Demo Video",
"filename": "demo-video.mp4",
"duration": 300,
"fileSize": 52428800,
"width": 1920,
"height": 1080,
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T14:30:00.000Z"
}
],
"total": 45,
"limit": 20,
"offset": 0
}
GET /api/media/public
List shared media with pagination, filtering, and sorting (no auth required).
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
| category | string | - | Filter by category |
| search | string | - | Search filename/path |
| sort | enum | recent |
Sort: recent, popular, most_viewed |
| orientation | string | - | Filter by orientation |
| limit | number | 24 | Results per page (max 100) |
| offset | number | 0 | Skip N results |
Example Request:
curl "http://localhost:4100/api/media/public?category=highlights&sort=popular&limit=12"
Response (200 OK):
{
"videos": [
{
"id": 456,
"filename": "highlight-2024-01-15.mp4",
"category": "highlights",
"durationSeconds": 45,
"quality": "1080p",
"orientation": "landscape",
"thumbnailPath": "/thumbnails/highlight-2024-01-15.jpg",
"viewCount": 1250,
"upvoteCount": 89,
"commentCount": 12,
"isLocked": false,
"createdAt": "2026-01-15T10:00:00.000Z"
}
],
"pagination": {
"total": 145,
"limit": 12,
"offset": 0,
"hasMore": true
}
}
Sort Modes:
switch (sort) {
case 'popular':
orderBy = [desc(publicMedia.upvoteCount), desc(publicMedia.createdAt)];
break;
case 'most_viewed':
orderBy = [desc(publicMedia.viewCount), desc(publicMedia.createdAt)];
break;
case 'recent':
default:
orderBy = [desc(publicMedia.createdAt)];
break;
}
GET /api/media/public/:id
Get single media details and increment view count (no auth required).
Path Parameters:
id(number): Media ID
Example Request:
curl "http://localhost:4100/api/media/public/456"
Response (200 OK):
{
"id": 456,
"path": "/public/highlights/highlight-2024-01-15.mp4",
"filename": "highlight-2024-01-15.mp4",
"category": "highlights",
"durationSeconds": 45,
"quality": "1080p",
"orientation": "landscape",
"thumbnailPath": "/thumbnails/highlight-2024-01-15.jpg",
"fileSize": 15728640,
"viewCount": 1251,
"upvoteCount": 89,
"commentCount": 12,
"finishCount": 420,
"totalWatchTime": 48600,
"isLocked": false,
"createdAt": "2026-01-15T10:00:00.000Z",
"updatedAt": "2026-02-11T15:45:00.000Z"
}
Side Effect:
View count is incremented fire-and-forget (does not block response):
// Increment view count (fire and forget)
db.update(publicMedia)
.set({ viewCount: sql`${publicMedia.viewCount} + 1` })
.where(eq(publicMedia.id, mediaId))
.execute()
.catch(err => logger.error({ err }, 'Failed to increment view count'));
POST /api/media/public/:id/upvote
Upvote media (session-based, no auth required).
Path Parameters:
id(number): Media ID
Request Body:
{
"sessionId": "sess_abc123def456"
}
Response (200 OK):
{
"success": true,
"upvoted": true,
"upvoteCount": 90
}
Behavior:
- Idempotent — If already upvoted, returns existing upvote
- Denormalized counter — Updates
publicMedia.upvoteCountatomically - Session-based — No authentication required
Duplicate Prevention:
// Check if already upvoted
const [existingVote] = await db
.select()
.from(upvotes)
.where(and(
eq(upvotes.mediaId, mediaId),
eq(upvotes.sessionId, sessionId)
));
if (existingVote) {
return reply.send({ success: true, upvoted: true, upvoteCount: media.upvoteCount });
}
DELETE /api/media/public/:id/upvote
Remove upvote (session-based).
Path Parameters:
id(number): Media ID
Query Parameters:
sessionId(string): Session ID
Response (200 OK):
{
"success": true,
"upvoted": false,
"upvoteCount": 89
}
POST /api/reactions
Add reaction to video (authenticated users only).
Request Body:
{
"mediaId": 456,
"reactionType": "love",
"videoTimestamp": 27
}
Response (200 OK):
{
"success": true,
"reaction": {
"id": 789,
"mediaId": 456,
"userId": 123,
"reactionType": "love",
"videoTimestamp": 27,
"emoji": "❤️",
"formattedTime": "0:27",
"createdAt": "2026-02-11T15:50:00.000Z"
}
}
Validation:
const REACTION_EMOJIS: Record<string, string> = {
like: '👍',
love: '❤️',
laugh: '😂',
wow: '😮',
sad: '😢',
angry: '😠',
};
if (!REACTION_EMOJIS[reactionType]) {
return fastify.httpErrors.badRequest('Invalid reaction type');
}
Time Formatting:
function formatVideoTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Examples:
// 27 → "0:27"
// 90 → "1:30"
// 3661 → "1:01:01"
GET /api/reactions
Get reactions (filterable by mediaId/userId).
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| mediaId | number | Filter by media ID |
| userId | string | Filter by user ID |
| limit | number | Results per page (default 50) |
Example Request:
curl "http://localhost:4100/api/reactions?mediaId=456&limit=20"
Response (200 OK):
{
"reactions": [
{
"id": 789,
"mediaId": 456,
"userId": 123,
"reactionType": "love",
"videoTimestamp": 27,
"emoji": "❤️",
"formattedTime": "0:27",
"createdAt": "2026-02-11T15:50:00.000Z"
},
{
"id": 790,
"mediaId": 456,
"userId": 124,
"reactionType": "laugh",
"videoTimestamp": 42,
"emoji": "😂",
"formattedTime": "0:42",
"createdAt": "2026-02-11T15:51:00.000Z"
}
]
}
GET /api/reactions/config
Get available reaction types.
Example Request:
curl "http://localhost:4100/api/reactions/config"
Response (200 OK):
{
"reactions": [
{ "type": "like", "emoji": "👍", "label": "Like" },
{ "type": "love", "emoji": "❤️", "label": "Love" },
{ "type": "laugh", "emoji": "😂", "label": "Laugh" },
{ "type": "wow", "emoji": "😮", "label": "Wow" },
{ "type": "sad", "emoji": "😢", "label": "Sad" },
{ "type": "angry", "emoji": "😠", "label": "Angry" }
]
}
Fastify vs Express Differences
| Feature | Express API (port 4000) | Fastify Media API (port 4100) |
|---|---|---|
| Framework | Express 5 | Fastify |
| ORM | Prisma | Drizzle |
| Schema Validation | Zod + middleware | Fastify built-in |
| Auth Middleware | authenticate, requireRole |
authenticate, requireAdminRole, optionalAuth |
| Error Handling | AppError class + error handler middleware |
fastify.httpErrors + decorators |
| Route Registration | router.get(...) |
fastify.register(routes, { prefix }) |
| Request Handler | (req, res, next) => {} |
async (request, reply) => {} |
| Database Client | import { prisma } |
import { db } |
| Query Builder | Prisma fluent API | Drizzle query builder |
Code Pattern Comparison
Express (Prisma):
import { Router } from 'express';
import { prisma } from '../../config/database';
import { authenticate, requireRole } from '../../middleware/auth.middleware';
const router = Router();
router.get('/', authenticate, requireRole('ADMIN'), async (req, res, next) => {
try {
const users = await prisma.user.findMany({
where: { role: 'ADMIN' },
select: { id: true, email: true },
});
res.json(users);
} catch (err) {
next(err);
}
});
export default router;
Fastify (Drizzle):
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
import { requireAdminRole } from '../middleware/auth';
export async function usersRoutes(fastify: FastifyInstance) {
fastify.get(
'/',
{ preHandler: requireAdminRole },
async (request: FastifyRequest, reply: FastifyReply) => {
const results = await db
.select({ id: users.id, email: users.email })
.from(users)
.where(eq(users.role, 'ADMIN'));
return reply.send(results);
}
);
}
Frontend Integration
The Media module integrates with multiple frontend pages:
Admin Pages
-
LibraryPage (
admin/src/pages/media/LibraryPage.tsx)- Video grid with thumbnails
- Filter by directory type
- Search by filename
- Bulk operations (lock, unlock, delete)
-
SharedMediaPage (
admin/src/pages/media/SharedMediaPage.tsx)- Public gallery admin
- Category management
- Lock/unlock controls
- Engagement metrics display
-
MediaJobsPage (
admin/src/pages/media/MediaJobsPage.tsx)- Job queue monitoring
- Job status tracking (pending, queued, running, completed, failed)
- Progress visualization
- Resource category filtering
Public Pages
-
MediaGalleryPage (
admin/src/pages/public/MediaGalleryPage.tsx)- Public video gallery
- Category filtering
- Sort by recent/popular/most viewed
- Upvote functionality (session-based)
- View count display
-
MediaViewerPage (
admin/src/pages/public/MediaViewerPage.tsx)- Video player with reactions
- Timestamped reactions overlay
- Comment section
- Related videos
- Share functionality
State Management:
// Admin: useMediaApi hook
const { videos, loading, error } = useMediaApi('/api/videos', {
limit: 24,
offset: 0,
search: '',
});
// Public: Direct axios calls to media API
const { data } = await axios.get('http://localhost:4100/api/media/public', {
params: { category: 'highlights', sort: 'popular', limit: 12 },
});
Performance Considerations
Denormalized Counters
The publicMedia table uses denormalized counters for engagement metrics:
viewCount: integer('view_count').default(0),
upvoteCount: integer('upvote_count').default(0),
commentCount: integer('comment_count').default(0),
finishCount: integer('finish_count').default(0),
totalWatchTime: integer('total_watch_time').default(0),
Pros:
- Fast sorting — No joins or aggregations needed
- Instant popularity ranking — Direct sorting on indexed columns
- Simple queries — No complex GROUP BY clauses
Cons:
- Consistency risk — Counters can drift if transactions fail
- Update overhead — Must update counter on every upvote/view
Mitigation:
- Use atomic updates:
sql\${publicMedia.viewCount} + 1`` - Run periodic reconciliation job to fix drift
Fire-and-Forget View Tracking
View count increments are fire-and-forget to avoid blocking response:
// Increment view count (fire and forget)
db.update(publicMedia)
.set({ viewCount: sql`${publicMedia.viewCount} + 1` })
.where(eq(publicMedia.id, mediaId))
.execute()
.catch(err => logger.error({ err }, 'Failed to increment view count'));
// Return immediately (don't await)
return reply.send(media);
Trade-off:
- Faster response — User doesn't wait for view count update
- Eventual consistency — View count may be slightly behind
Fingerprint-Based Deduplication
The videos table includes a composite index for fast duplicate detection:
fingerprintIdx: index('idx_videos_fingerprint').on(
table.durationSeconds, table.fileSize, table.width, table.height
),
Usage:
const duplicates = await db
.select()
.from(videos)
.where(and(
eq(videos.durationSeconds, newVideo.durationSeconds),
eq(videos.fileSize, newVideo.fileSize),
eq(videos.width, newVideo.width),
eq(videos.height, newVideo.height),
));
if (duplicates.length > 0 && duplicates[0].fileHash === newVideo.fileHash) {
throw new Error('Duplicate video detected');
}
Why Fingerprint Index:
- Fast pre-filter — Index lookup narrows candidates
- File hash check — Confirms exact duplicate (expensive, only on candidates)
- Two-stage approach — Balances speed and accuracy
Troubleshooting
Media API Not Starting
Problem:
Docker logs show "Media API server closed" immediately.
Diagnosis:
Check env vars:
docker compose exec api printenv | grep MEDIA
Required vars:
MEDIA_API_PORT=4100
ENABLE_MEDIA_FEATURES=true
MAX_UPLOAD_SIZE_GB=10
Solution:
- Verify
ENABLE_MEDIA_FEATURES=truein.env - Check port conflicts:
lsof -i :4100 - Check database connection (shares same DATABASE_URL)
CORS Errors on Media API
Problem:
Frontend gets CORS errors when calling media API endpoints.
Diagnosis:
Check CORS origins:
CORS_ORIGINS=http://localhost:3000,http://localhost:3010
Behavior:
await fastify.register(cors, {
origin: (origin, cb) => {
if (!origin) {
cb(null, true); // Allow no origin (mobile, curl)
return;
}
if (allowedOrigins.includes(origin)) {
cb(null, true);
} else {
cb(new Error('CORS not allowed'), false);
}
},
credentials: true,
});
Solution:
Add missing origins to CORS_ORIGINS in .env:
CORS_ORIGINS=http://localhost:3000,http://localhost:3010,http://localhost:3100
Upvote Not Working
Problem:
Upvote button doesn't work, returns 400 error.
Diagnosis:
Check request body:
curl -X POST \
-H "Content-Type: application/json" \
-d '{"sessionId":"sess_abc123"}' \
http://localhost:4100/api/media/public/456/upvote
Common Issues:
-
Missing sessionId:
{ "error": "sessionId is required" } -
Media not found:
{ "error": "Media not found" } -
Locked media:
{ "error": "Media is locked" }
Solution:
- Generate session ID in frontend:
crypto.randomUUID()ornanoid() - Verify media exists in
public_mediatable - Check
isLockedstatus
Reactions Not Appearing
Problem:
Reactions submitted but not appearing in frontend.
Diagnosis:
Check reaction data:
SELECT * FROM video_reactions WHERE "mediaId" = 456 ORDER BY "createdAt" DESC LIMIT 10;
Verify:
userIdmatches authenticated usermediaIdmatches video IDreactionTypeis valid emoji type
Common Issues:
-
Authentication failed:
- Reaction requires auth
- Check JWT token in Authorization header
-
Invalid reaction type:
{ "error": "Invalid reaction type" } -
Video not found:
{ "error": "Video not found" }
Solution:
- Verify JWT token is valid and not expired
- Use valid reaction types:
like,love,laugh,wow,sad,angry - Check video exists in
videostable (not justpublic_media)
Job Queue Not Processing
Problem:
Jobs stuck in pending status, never transition to running.
Diagnosis:
Check job queue:
SELECT id, type, status, "resourceCategory", "queuePosition", "waitingReason"
FROM jobs
WHERE status IN ('pending', 'queued')
ORDER BY priority DESC, "createdAt" ASC;
Common Issues:
-
No worker running:
- Check if job worker process is running
- Verify
ENABLE_MEDIA_FEATURES=true
-
Resource exhaustion:
- GPU jobs waiting for VRAM
- Check
vramRequiredvs available VRAM
-
Pipeline blocking:
- Pipeline step depends on previous step completion
Solution:
- Start job worker:
npm run worker:mediaor check Docker Compose - Adjust resource limits or priority
- Check pipeline configuration for blocking issues
Related Documentation
- Dual API Architecture - Express + Fastify architecture
- Drizzle ORM - Drizzle query builder (media tables)
- Frontend: LibraryPage - Video library management UI
- Frontend: MediaGalleryPage - Public gallery
- Frontend: MediaViewerPage - Video player with reactions
- Features: Media Manager - Complete feature guide
- API Reference: Media - Complete endpoint reference
- User Guide: Media Admin - Managing video library
- Troubleshooting: Media API Issues - Debugging guide