# 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(), // Directory type for efficient filtering directoryType: text('directory_type').$type(), // 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(), progress: integer('progress').default(0), log: text('log'), params: jsonb('params').$type>(), 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(), 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(), settings: jsonb('settings').$type>(), 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 " \ "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 = { 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