1165 lines
30 KiB
Markdown
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
|