1165 lines
30 KiB
Markdown

# 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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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:**
```bash
curl -H "Authorization: Bearer <token>" \
"http://localhost:4100/api/videos?limit=20&offset=0&search=demo"
```
**Response (200 OK):**
```json
{
"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:**
```bash
curl "http://localhost:4100/api/media/public?category=highlights&sort=popular&limit=12"
```
**Response (200 OK):**
```json
{
"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:**
```typescript
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:**
```bash
curl "http://localhost:4100/api/media/public/456"
```
**Response (200 OK):**
```json
{
"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):
```typescript
// 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:**
```json
{
"sessionId": "sess_abc123def456"
}
```
**Response (200 OK):**
```json
{
"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:**
```typescript
// 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):**
```json
{
"success": true,
"upvoted": false,
"upvoteCount": 89
}
```
---
### POST /api/reactions
Add reaction to video (authenticated users only).
**Request Body:**
```json
{
"mediaId": 456,
"reactionType": "love",
"videoTimestamp": 27
}
```
**Response (200 OK):**
```json
{
"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:**
```typescript
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:**
```typescript
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:**
```bash
curl "http://localhost:4100/api/reactions?mediaId=456&limit=20"
```
**Response (200 OK):**
```json
{
"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:**
```bash
curl "http://localhost:4100/api/reactions/config"
```
**Response (200 OK):**
```json
{
"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):**
```typescript
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):**
```typescript
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:**
```typescript
// 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:
```typescript
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:
```typescript
// 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:
```typescript
fingerprintIdx: index('idx_videos_fingerprint').on(
table.durationSeconds, table.fileSize, table.width, table.height
),
```
**Usage:**
```typescript
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:
```bash
docker compose exec api printenv | grep MEDIA
```
**Required vars:**
```env
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:
```env
CORS_ORIGINS=http://localhost:3000,http://localhost:3010
```
**Behavior:**
```typescript
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`:
```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:
```bash
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:**
```json
{ "error": "sessionId is required" }
```
2. **Media not found:**
```json
{ "error": "Media not found" }
```
3. **Locked media:**
```json
{ "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:
```sql
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:**
```json
{ "error": "Invalid reaction type" }
```
3. **Video not found:**
```json
{ "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:
```sql
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
---
## Related Documentation
- [Dual API Architecture](/v2/architecture/dual-api.md) - Express + Fastify architecture
- [Drizzle ORM](/v2/database/drizzle.md) - Drizzle query builder (media tables)
- [Frontend: LibraryPage](/v2/frontend/pages/media/library-page.md) - Video library management UI
- [Frontend: MediaGalleryPage](/v2/frontend/pages/public/media-gallery-page.md) - Public gallery
- [Frontend: MediaViewerPage](/v2/frontend/pages/public/media-viewer-page.md) - Video player with reactions
- [Features: Media Manager](/v2/features/media/overview.md) - Complete feature guide
- [API Reference: Media](/v2/api-reference/media.md) - Complete endpoint reference
- [User Guide: Media Admin](/v2/user-guides/media-admin-guide.md) - Managing video library
- [Troubleshooting: Media API Issues](/v2/troubleshooting/media-issues.md) - Debugging guide