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 compilation
  • scan, public_scan — Video library scanning
  • organize, organize_studio — Automatic organization
  • reencode_streaming — Transcode for web streaming
  • compile_random, compile_quad, compile_quad_horizontal, etc. — Compilation variants
  • generate_gif, fetch, digest, clip_generate, highlight_generate — Content generation
  • tag_generation, scene_extract, clip_extract_only, auto_organize_publish — AI-powered tasks

Resource Categories:

  • gpu_ai — AI/ML tasks (scene detection, tagging, etc.) — High VRAM
  • gpu_encode — Video encoding/transcoding — Medium VRAM
  • cpu — 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.upvoteCount atomically
  • 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=true in .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:

  1. Missing sessionId:

    { "error": "sessionId is required" }
    
  2. Media not found:

    { "error": "Media not found" }
    
  3. Locked media:

    { "error": "Media is locked" }
    

Solution:

  • Generate session ID in frontend: crypto.randomUUID() or nanoid()
  • Verify media exists in public_media table
  • Check isLocked status

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:

  • userId matches authenticated user
  • mediaId matches video ID
  • reactionType is valid emoji type

Common Issues:

  1. Authentication failed:

    • Reaction requires auth
    • Check JWT token in Authorization header
  2. Invalid reaction type:

    { "error": "Invalid reaction type" }
    
  3. 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 videos table (not just public_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:

  1. No worker running:

    • Check if job worker process is running
    • Verify ENABLE_MEDIA_FEATURES=true
  2. Resource exhaustion:

    • GPU jobs waiting for VRAM
    • Check vramRequired vs available VRAM
  3. Pipeline blocking:

    • Pipeline step depends on previous step completion

Solution:

  • Start job worker: npm run worker:media or check Docker Compose
  • Adjust resource limits or priority
  • Check pipeline configuration for blocking issues